diff --git a/src/crates/assembly/core/src/service/workspace/service.rs b/src/crates/assembly/core/src/service/workspace/service.rs index 053f3ada54..9e77e86a8d 100644 --- a/src/crates/assembly/core/src/service/workspace/service.rs +++ b/src/crates/assembly/core/src/service/workspace/service.rs @@ -1748,7 +1748,12 @@ impl WorkspaceService { .recent_workspaces .clone() .into_iter() - .filter(|id| manager.get_workspaces().contains_key(id)) + .filter(|id| { + manager.get_workspaces().get(id).is_some_and(|workspace| { + // Drop MiniApp-owned workspaces recorded before they were excluded. + !self.is_miniapp_owned_path(&workspace.root_path) + }) + }) .collect(); if filtered_recent != data.recent_workspaces { should_persist_cleaned_history = true; @@ -2111,9 +2116,20 @@ impl WorkspaceService { } } + if self.is_miniapp_owned_path(path) { + options.add_to_recent = false; + } + options } + /// MiniApp agent runs and customization drafts work inside directories the + /// MiniApp owns under `/data/miniapps/`. They are app storage, not + /// user projects, so they must stay out of recent workspace history. + fn is_miniapp_owned_path(&self, path: &Path) -> bool { + path.starts_with(self.path_manager.miniapps_dir()) + } + async fn discover_assistant_workspaces( &self, ) -> BitFunResult> { @@ -2579,6 +2595,41 @@ mod tests { ); } + #[tokio::test] + async fn track_workspace_activity_keeps_miniapp_workspaces_out_of_recent_history() { + let env = TestEnvironment::new(); + let service = build_test_workspace_service(env.path_manager.clone()).await; + let miniapp_workspace_root = env + .path_manager + .miniapp_dir("builtin-ppt-live") + .join("decks") + .join("deck-1785130332234"); + std::fs::create_dir_all(&miniapp_workspace_root) + .expect("MiniApp workspace directory should be created"); + + let tracked = service + .track_workspace_activity( + miniapp_workspace_root.clone(), + WorkspaceCreateOptions::default(), + WorkspaceActivityMode::RefreshMetadata, + ) + .await + .expect("MiniApp workspace tracking should succeed"); + + assert_eq!( + service + .get_workspace_by_path(&miniapp_workspace_root) + .await + .map(|workspace| workspace.id), + Some(tracked.id), + "MiniApp workspace should still be registered so its agent session can resolve it" + ); + assert!( + service.get_recent_workspaces().await.is_empty(), + "MiniApp-owned workspaces should never enter recent workspace history" + ); + } + #[tokio::test] async fn touch_only_workspace_activity_preserves_worktree_metadata() { let env = TestEnvironment::new(); diff --git a/src/web-ui/src/shared/types/global-state.test.ts b/src/web-ui/src/shared/types/global-state.test.ts index d607adaaa5..cf6cba43ea 100644 --- a/src/web-ui/src/shared/types/global-state.test.ts +++ b/src/web-ui/src/shared/types/global-state.test.ts @@ -99,6 +99,24 @@ describe('createGlobalStateAPI workspace startup bootstrap', () => { expect(globalApiMocks.initializeWorkspaceStartupState).toHaveBeenCalledTimes(1); }); + it('keeps MiniApp-owned workspaces out of the recent list', async () => { + const snapshot = createWorkspaceSnapshot(); + const miniAppWorkspace = { + ...snapshot.recentWorkspaces[0], + id: 'workspace-miniapp', + name: 'deck-1785130332234-eilik4', + rootPath: + '/Users/me/Library/Application Support/bitfun/data/miniapps/builtin-ppt-live/decks/deck-1785130332234-eilik4', + }; + snapshot.recentWorkspaces = [...snapshot.recentWorkspaces, miniAppWorkspace]; + bootstrapGlobals.__BITFUN_BOOTSTRAP_WORKSPACE_STARTUP_STATE__ = snapshot; + + const api = createGlobalStateAPI(); + const state = await api.initializeWorkspaceStartupState(); + + expect(state.recentWorkspaces.map(workspace => workspace.id)).toEqual(['workspace-1']); + }); + it('falls back to the startup command when the bootstrap snapshot is invalid', async () => { bootstrapGlobals.__BITFUN_BOOTSTRAP_WORKSPACE_STARTUP_STATE__ = { recentWorkspaces: [] }; globalApiMocks.initializeWorkspaceStartupState.mockResolvedValue(createWorkspaceSnapshot()); diff --git a/src/web-ui/src/shared/types/global-state.ts b/src/web-ui/src/shared/types/global-state.ts index 2774c94a70..d289d417da 100644 --- a/src/web-ui/src/shared/types/global-state.ts +++ b/src/web-ui/src/shared/types/global-state.ts @@ -11,6 +11,7 @@ import type { WorkspaceInfo as APIWorkspaceInfo, } from '@/infrastructure/api/service-api/GlobalAPI'; import { createLogger } from '../utils/logger'; +import { normalizePath } from '../utils/pathUtils'; const logger = createLogger('GlobalStateAPI'); @@ -151,6 +152,27 @@ export function isLinkedWorktreeWorkspace(workspace: WorkspaceInfo | null | unde return Boolean(workspace?.worktree && !workspace.worktree.isMain); } +/** MiniApp storage root shared by every platform: `/data/miniapps/`. */ +const MINIAPP_DATA_PATH_SEGMENT = '/data/miniapps/'; + +/** + * MiniApp agent runs work inside directories the MiniApp owns under the app data + * dir (`/data/miniapps//...`, drafts included). They are MiniApp + * storage rather than user projects, so they never belong in workspace history. + */ +export function isMiniAppWorkspace(workspace: WorkspaceInfo | null | undefined): boolean { + const rootPath = workspace?.rootPath; + if (!rootPath) { + return false; + } + return normalizePath(rootPath).includes(MINIAPP_DATA_PATH_SEGMENT); +} + +/** Temporary or app-owned workspaces that must not pollute the recent list. */ +function isExcludedFromRecentWorkspaces(workspace: WorkspaceInfo): boolean { + return isLinkedWorktreeWorkspace(workspace) || isMiniAppWorkspace(workspace); +} + export enum WorkspaceAction { Opened = 'opened', @@ -395,7 +417,7 @@ function mapWorkspaceStartupStateSnapshot( ): WorkspaceStartupState { const recentWorkspaces = snapshot.recentWorkspaces .map(mapWorkspaceInfo) - .filter(ws => !isLinkedWorktreeWorkspace(ws)); + .filter(ws => !isExcludedFromRecentWorkspaces(ws)); return { cleanupRemovedCount: snapshot.cleanupRemovedCount, currentWorkspace: snapshot.currentWorkspace ? mapWorkspaceInfo(snapshot.currentWorkspace) : null, @@ -569,7 +591,7 @@ export function createGlobalStateAPI(): GlobalStateAPI { async getRecentWorkspaces(): Promise { const workspaces = (await globalAPI.getRecentWorkspaces()) .map(mapWorkspaceInfo) - .filter(ws => !isLinkedWorktreeWorkspace(ws)); + .filter(ws => !isExcludedFromRecentWorkspaces(ws)); logger.debug('getRecentWorkspaces returned', summarizeWorkspacesForLog(workspaces)); return workspaces; },