diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 381ffb0c86..ed8e271c18 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -831,7 +831,7 @@ "react": 1 }, "importSpecifiers": 98, - "nonTriviaTokens": 12729 + "nonTriviaTokens": 12728 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 0, diff --git a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts index 5fd4b20141..7429f9913a 100644 --- a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts +++ b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts @@ -739,4 +739,33 @@ describe('useTaskEntryController', () => { profileId: 'local', }]); }); + + it('explains that a running Session must settle before workspace recovery', async () => { + const { root } = installReactRenderer(); + const errors: unknown[] = []; + const services = createFakeTaskEntryServices({ + catalog: { + ...createFakeTaskEntryServices().catalog, + getCatalog: async () => catalog(), + }, + sessions: { + relocateWorkspace: async () => ({ ok: false, reason: 'session_busy' }), + }, + }); + + await act(async () => renderController(root, services, errors)); + await act(async () => { + await controller().commands.relocateSessionWorkspace({ + sessionId: 'session-1', + profileId: 'local', + projectId: 'project-a', + }); + }); + + assert.deepEqual(errors, [{ + title: 'Could not move task', + description: 'A task is running. Wait for it to finish before moving this one.', + profileId: 'local', + }]); + }); }); diff --git a/apps/desktop/src/main/__tests__/task-entry-provider-scope.test.ts b/apps/desktop/src/main/__tests__/task-entry-provider-scope.test.ts index 484acb8518..c6aa62d3dc 100644 --- a/apps/desktop/src/main/__tests__/task-entry-provider-scope.test.ts +++ b/apps/desktop/src/main/__tests__/task-entry-provider-scope.test.ts @@ -19,8 +19,8 @@ import { strict as assert } from 'node:assert'; import { afterEach, describe, it } from 'node:test'; -import { act, createElement, Fragment } from 'react'; -import { LocaleProvider, ToastProvider } from '@maka/ui'; +import { act, createElement, Fragment, type ReactNode } from 'react'; +import { LocaleProvider, ToastProvider, type WorkspacePickerModel } from '@maka/ui'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { createFakeTaskEntryServices, @@ -42,6 +42,9 @@ let latestTaskEntry: TaskEntryShellProjection | undefined; let latestDirectoryHostId: string | undefined; let latestProjectDialog: ReturnType['newProjectDialog']; let latestWorkspaceGroupCount = 0; +let latestRecoveryPicker: WorkspacePickerModel | undefined; +let recoverySelectedProject: string | undefined; +let recoveryPickerOpenStates: boolean[] = []; function project(id: string) { return { @@ -70,10 +73,31 @@ function remoteHost(): Extract { }; } +function localHost(projects: ReturnType[] = []): Extract { + return { + profile: { id: 'local', name: 'Local', kind: 'local' }, + hostId: 'host-local', + readiness: 'ready', + state: 'available', + projects, + capabilities: { + chooseClientDirectory: true, + chooseHostDirectory: false, + selectNoProject: true, + }, + selectedProjectId: projects[0]?.id, + chatDefaults: { permissionMode: 'ask', thinkingLevel: 'high' }, + }; +} + function catalog(): TaskEntryCatalog { return { defaultProfileId: 'remote', hosts: [remoteHost()] }; } +function localCatalog(projects: ReturnType[] = []): TaskEntryCatalog { + return { defaultProfileId: 'local', hosts: [localHost(projects)] }; +} + function WorkspaceProbe() { return createElement(TaskEntryWorkspacePickerConsumer, { manageProjects() {}, @@ -93,6 +117,34 @@ function HostProbe() { return null; } +function RecoveryWorkspaceProbe({ local = false }: { local?: boolean }) { + return createElement(TaskEntryWorkspacePickerConsumer, { + manageProjects() {}, + activeSession: local + ? { + id: 'session-1', + profileId: 'local', + runtimeHostId: 'host-local', + projectId: null, + profileKind: 'local', + } + : { + id: 'session-1', + profileId: 'remote', + runtimeHostId: 'host-remote', + projectId: 'missing-project', + profileKind: 'remote', + }, + children: (workspacePicker) => { + latestRecoveryPicker = workspacePicker; + if (workspacePicker.showForActiveSession) { + recoveryPickerOpenStates.push(workspacePicker.isMenuOpen === true); + } + return null; + }, + }); +} + function FrameProbe() { frameRenders += 1; return createElement(Fragment, null, createElement(WorkspaceProbe), createElement(HostProbe)); @@ -108,9 +160,28 @@ function ShellProbe() { }); } +function RecoveryShellProbe() { + return createElement(TaskEntryRoot, { + children: (taskEntry) => { + latestTaskEntry = taskEntry; + return createElement(RecoveryWorkspaceProbe); + }, + }); +} + +function LocalRecoveryShellProbe() { + return createElement(TaskEntryRoot, { + children: (taskEntry) => { + latestTaskEntry = taskEntry; + return createElement(RecoveryWorkspaceProbe, { local: true }); + }, + }); +} + function renderProvider( root: ReturnType['root'], services: TaskEntryServices, + probe: ReactNode = createElement(ShellProbe), ) { root.render( createElement(LocaleProvider, { @@ -121,7 +192,7 @@ function renderProvider( createElement( TaskEntryServicesProvider, { services }, - createElement(ShellProbe), + probe, ), ), }), @@ -137,6 +208,9 @@ afterEach(() => { latestDirectoryHostId = undefined; latestProjectDialog = undefined; latestWorkspaceGroupCount = 0; + latestRecoveryPicker = undefined; + recoverySelectedProject = undefined; + recoveryPickerOpenStates = []; cleanupFakeDom(); }); @@ -220,4 +294,107 @@ describe('TaskEntryRoot render scope', () => { await act(async () => root.unmount()); }); + + it('scopes active-session recovery to available projects on that Host', async () => { + const { root } = installReactRenderer(); + const services = createFakeTaskEntryServices({ + catalog: { + ...createFakeTaskEntryServices().catalog, + getCatalog: async () => catalog(), + }, + sessions: { + relocateWorkspace: async (_sessionId, projectId) => { + recoverySelectedProject = projectId; + return { ok: true }; + }, + }, + }); + + await act(async () => { + root.render( + createElement(LocaleProvider, { + locale: 'en', + children: createElement( + ToastProvider, + null, + createElement( + TaskEntryServicesProvider, + { services }, + createElement(RecoveryShellProbe), + ), + ), + }), + ); + }); + + await act(async () => { + latestTaskEntry?.commands.openSessionWorkspaceRecovery('session-1'); + }); + + assert.equal(latestRecoveryPicker?.showForActiveSession, true); + assert.equal(latestRecoveryPicker?.isMenuOpen, true); + assert.deepEqual(recoveryPickerOpenStates.slice(-2), [false, true]); + assert.equal(latestRecoveryPicker?.groups.length, 1); + assert.deepEqual( + latestRecoveryPicker?.groups[0]?.projects.map(({ id }) => id), + ['project-a'], + ); + await act(async () => latestRecoveryPicker?.onOpenChange?.(false)); + assert.equal(latestRecoveryPicker?.isMenuOpen, false); + assert.equal(latestRecoveryPicker?.showForActiveSession, true); + // The readiness action is repeatable after dismissing its menu. + await act(async () => latestTaskEntry?.commands.openSessionWorkspaceRecovery('session-1')); + assert.equal(latestRecoveryPicker?.isMenuOpen, true); + await act(async () => { + latestRecoveryPicker?.groups[0]?.onSelectProject?.('project-a'); + await Promise.resolve(); + }); + assert.equal(recoverySelectedProject, 'project-a'); + assert.equal(latestRecoveryPicker?.showForActiveSession, undefined); + + await act(async () => root.unmount()); + }); + + it('adds a local Project before relocating the active Session', async () => { + const { root } = installReactRenderer(); + const calls: string[] = []; + let reads = 0; + const services = createFakeTaskEntryServices({ + catalog: { + ...createFakeTaskEntryServices().catalog, + getCatalog: async () => { + reads += 1; + return reads === 1 ? localCatalog() : localCatalog([project('project-new')]); + }, + addProject: async (host, name) => { + calls.push(`add:${host.profileId}:${host.hostId}:${name}`); + return { ok: true, project: project('project-new') }; + }, + }, + sessions: { + relocateWorkspace: async (sessionId, projectId) => { + calls.push(`relocate:${sessionId}:${projectId}`); + return { ok: true }; + }, + }, + }); + + await act(async () => renderProvider(root, services, createElement(LocalRecoveryShellProbe))); + await act(async () => latestTaskEntry?.commands.openSessionWorkspaceRecovery('session-1')); + assert.equal(typeof latestRecoveryPicker?.groups[0]?.onAdd, 'function'); + + // DropdownMenu closes when New project is selected, before the naming + // dialog submits. Its owning picker and Session context must survive. + await act(async () => latestRecoveryPicker?.onOpenChange?.(false)); + assert.equal(latestRecoveryPicker?.showForActiveSession, true); + assert.equal(latestRecoveryPicker?.isMenuOpen, false); + await act(async () => { + latestRecoveryPicker?.groups[0]?.onAdd?.('Imported'); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + assert.deepEqual(calls, ['add:local:host-local:Imported', 'relocate:session-1:project-new']); + assert.equal(latestRecoveryPicker?.showForActiveSession, undefined); + await act(async () => root.unmount()); + }); }); diff --git a/apps/desktop/src/main/__tests__/task-entry-services-adapter.test.ts b/apps/desktop/src/main/__tests__/task-entry-services-adapter.test.ts index 4fc08b877e..f4a9635da2 100644 --- a/apps/desktop/src/main/__tests__/task-entry-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/task-entry-services-adapter.test.ts @@ -60,7 +60,13 @@ describe('createDesktopTaskEntryServices', () => { restore: async (...args: unknown[]) => calls.push({ name: 'restoreProject', args }), }, - } as unknown as Pick; + sessions: { + moveToProject: async (...args: unknown[]) => { + calls.push({ name: 'moveToProject', args }); + return { ok: true, session: {} }; + }, + }, + } as unknown as Pick; const services = createDesktopTaskEntryServices(bridge); const host = { profileId: 'remote', hostId: 'host-1' }; @@ -74,6 +80,10 @@ describe('createDesktopTaskEntryServices', () => { await services.catalog.renameProject(host, 'project-1', 'Renamed'); await services.catalog.archiveProject(host, 'project-1'); await services.catalog.restoreProject(host, 'project-1'); + assert.deepEqual( + await services.sessions.relocateWorkspace('session-1', 'project-1'), + { ok: true }, + ); unsubscribe(); assert.deepEqual(calls, [ @@ -84,6 +94,10 @@ describe('createDesktopTaskEntryServices', () => { { name: 'renameProject', args: ['project-1', 'Renamed', host] }, { name: 'archiveProject', args: ['project-1', host] }, { name: 'restoreProject', args: ['project-1', host] }, + { + name: 'moveToProject', + args: ['session-1', 'project-1'], + }, ]); assert.equal(changes, 1); assert.equal(disposed, 1); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index e8ecc9fb4d..59f80b5943 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -362,7 +362,7 @@ function AppShellContent({ const onboarding = useOnboardingSnapshot(); // The owner bridge keeps commands stable while TaskEntryRoot swaps the // current feature-owned implementation below the shell. - const { resolveWorkBoardTarget, prepareWorkBoardDraft } = taskEntry.commands; + const { resolveWorkBoardTarget, prepareWorkBoardDraft, openSessionWorkspaceRecovery } = taskEntry.commands; const currentNewTaskDraftKey = taskEntry.selectors.draftKey; // Staged files and quotes do NOT take the target-scoped key: they belong to // the composer the user is looking at, and an in-flight send needs an owner @@ -495,9 +495,8 @@ function AppShellContent({ target: { kind: 'session', sessionId: ownerActiveId }, }); const startupConnectionSnapshot = onboarding.snapshot; - const newTaskUsesDefaultHost = taskEntry.selectors.usesDefaultHost; let newTaskConnectionSnapshot = newTaskConnections.snapshot; - if (newTaskConnections.projection.status !== 'ready' && newTaskUsesDefaultHost) { + if (newTaskConnections.projection.status !== 'ready' && taskEntry.selectors.usesDefaultHost) { newTaskConnectionSnapshot = defaultHostConnections.projection.status === 'ready' ? defaultHostConnections.snapshot : defaultHostConnections.projection.status === 'unrequested' && startupConnectionSnapshot @@ -672,8 +671,6 @@ function AppShellContent({ } : undefined; const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined; - const activeMessageSubmitting = transientMessages.length > 0; - const activeDesktopSession = activeSession; // The shell's reading of the active live turn: streaming/settled flags, the // in-flight tool signal, and the #646 turn-wait cues, all derived from the // semantic snapshot rather than the projection (#1985). @@ -706,10 +703,10 @@ function AppShellContent({ ? onboarding.snapshot?.sessionSendOutcomes[activeSession.id] : undefined; const composerProfileId = activeId - ? activeDesktopSession?.profileId + ? activeSession?.profileId : taskEntry.selectors.selectedProfileId; const composerProfileName = activeId - ? activeDesktopSession?.profileName + ? activeSession?.profileName : taskEntry.selectors.selectedHost?.name; const modelSettingsOwnsComposerHost = composerProfileId !== undefined && @@ -1044,7 +1041,7 @@ function AppShellContent({ // no sessions is not onboarding: they land on the normal empty chat and use // the one real Composer, which creates the session on its first send. const showOnboardingHero = - sessionCount === 0 && + !sessionCount && !onboardingSettled && onboardingState !== undefined && onboardingState.kind !== 'ready_with_history' && @@ -1108,7 +1105,7 @@ function AppShellContent({ sessionId: ownerActiveId, sessionCwd: sharedSessionActive ? undefined : activeSession?.cwd, sessionProjectId: sharedSessionActive ? undefined : activeSession?.projectId, - sessionProfileKind: sharedSessionActive ? undefined : activeDesktopSession?.profileKind, + sessionProfileKind: sharedSessionActive ? undefined : activeSession?.profileKind, onProjectSelected: (ownerSessionId) => { void moduleHubCommands.refreshProjectSkills(); if (ownerSessionId && activeIdRef.current === ownerSessionId) openNewTaskSurface(); @@ -2227,12 +2224,12 @@ function AppShellContent({ readOnly={sharedSessionActive} action={ sharedSessionActive || - !activeDesktopSession || - activeDesktopSession.profileKind === 'environment' + !activeSession || + activeSession.profileKind === 'environment' ? undefined : { label: sharedSessionDialog.shareActionLabel, - onClick: () => sharedSessionDialog.openSession(activeDesktopSession), + onClick: () => sharedSessionDialog.openSession(activeSession), } } onRenameSession={(name) => { @@ -2366,7 +2363,9 @@ function AppShellContent({ sessionId={activeId} /> ) : ( - + {(workspacePicker) => ( 0} onSend={sendOwningItsTarget} onStop={stop} pendingMessages={transientMessages} @@ -2600,7 +2599,7 @@ function AppShellContent({ onTaskReadinessAction={ taskReadinessNotice?.action === 'workspace_picker' ? activeSession - ? openNewTaskSurface + ? () => openSessionWorkspaceRecovery(activeSession.id) : taskEntry.selectors.canAddProject ? taskEntry.commands.addProject : undefined diff --git a/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts index 08c936d871..8eabfedf32 100644 --- a/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts +++ b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts @@ -77,6 +77,7 @@ export interface TaskEntryControllerSelectors { readonly defaultProfileId: string; readonly usesDefaultHost: boolean; readonly workspacePicker: WorkspacePickerModel; + readonly sessionWorkspaceRecovery?: { readonly sessionId: string }; readonly canAddProject: boolean; /** Every ready Host's Projects, with the owning Host retained as identity. */ readonly projectScopes: readonly TaskEntryProjectScope[]; @@ -94,6 +95,19 @@ export interface TaskEntryControllerCommands { addProject(name?: string): void; openNewProject(): void; chooseProjectForProfile(profileId: string): Promise; + openSessionWorkspaceRecovery(sessionId: string): void; + closeSessionWorkspaceRecovery(): void; + relocateSessionWorkspace(input: { + sessionId: string; + profileId: string; + projectId: string; + }): Promise; + addSessionWorkspace(input: { + sessionId: string; + profileId: string; + host: TaskEntryHostRef; + name: string; + }): Promise; resolveWorkBoardTarget(item: WorkBoardItem): WorkBoardStartTargetResult; prepareWorkBoardDraft(target: TaskEntryTarget, draft: string): string | undefined; } @@ -150,10 +164,11 @@ export function useTaskEntryController( ): TaskEntryController { const locale = useUiLocale(); const copy = getShellCopy(locale).projectActions; + const sessionMoveCopy = getShellCopy(locale).sessionRowActions; const conversationCopy = getConversationCopy(locale).workspace; const reportError = input.reportError; const manageProjects = input.manageProjects; - const { catalog: service } = useTaskEntryServices(); + const { catalog: service, sessions: sessionService } = useTaskEntryServices(); const [catalog, setCatalog] = useState(EMPTY_CATALOG); const [selectedProfileId, setSelectedProfileId] = useState(); const [projectSelections, setProjectSelections] = useState( @@ -162,6 +177,8 @@ export function useTaskEntryController( const [pending, setPending] = useState(false); const [refreshing, setRefreshing] = useState(true); const [error, setError] = useState(); + const [sessionWorkspaceRecovery, setSessionWorkspaceRecovery] = + useState(); const [directoryHost, setDirectoryHost] = useState(); const [newProjectOpen, setNewProjectOpen] = useState(false); const directoryOpenerRef = useRef(null); @@ -297,6 +314,79 @@ export function useTaskEntryController( } }, [copy.projectUpdateFailedFallback, copy.projectUpdateFailedTitle, locale, refresh, reportError]); + const openSessionWorkspaceRecovery = useCallback((sessionId: string): void => { + // A fresh request reopens the menu even if this Session is already being repaired. + setSessionWorkspaceRecovery({ sessionId }); + }, []); + + const closeSessionWorkspaceRecovery = useCallback((): void => { + setSessionWorkspaceRecovery(undefined); + }, []); + + const relocateSessionWorkspace = useCallback(async (input: { + sessionId: string; + profileId: string; + projectId: string; + }): Promise => { + try { + const result = await sessionService.relocateWorkspace(input.sessionId, input.projectId); + if (!result.ok) { + reportError({ + title: sessionMoveCopy.moveFailedTitle, + description: sessionMoveCopy.moveFailures[result.reason], + profileId: input.profileId, + }); + return false; + } + closeSessionWorkspaceRecovery(); + return true; + } catch (cause) { + reportError({ + title: copy.projectUpdateFailedTitle, + description: localizedShellErrorMessage( + cause, + copy.projectUpdateFailedFallback, + locale, + ), + profileId: input.profileId, + }); + return false; + } + }, [ + closeSessionWorkspaceRecovery, + copy.projectUpdateFailedFallback, + copy.projectUpdateFailedTitle, + locale, + reportError, + sessionMoveCopy, + sessionService, + ]); + + const addSessionWorkspace = useCallback(async (input: { + sessionId: string; + profileId: string; + host: TaskEntryHostRef; + name: string; + }): Promise => { + try { + const result = await service.addProject(input.host, input.name); + if (!result.ok) return false; + await refreshAfterProjectMutation(input.host.profileId); + return relocateSessionWorkspace({ + sessionId: input.sessionId, + profileId: input.profileId, + projectId: result.project.id, + }); + } catch (cause) { + reportError({ + title: copy.selectDirectoryFailedTitle, + description: localizedShellErrorMessage(cause, copy.readPathFailedFallback, locale), + profileId: input.host.profileId, + }); + return false; + } + }, [copy.readPathFailedFallback, copy.selectDirectoryFailedTitle, locale, refreshAfterProjectMutation, relocateSessionWorkspace, reportError, service]); + const addProjectForHost = useCallback(async (host: ReadyTaskEntryHost, name?: string): Promise => { if (projectMutationPendingRef.current) return; if (host.capabilities.chooseHostDirectory) { @@ -469,6 +559,7 @@ export function useTaskEntryController( : host.selectedProjectId; return { id: host.profile.id, + hostId: host.hostId, label: host.profile.name, projects: host.projects.filter((project) => project.archivedAt === undefined), selectedProjectId: groupSelectedProjectId, @@ -692,6 +783,10 @@ export function useTaskEntryController( addProject: addSelectedProject, openNewProject, chooseProjectForProfile, + openSessionWorkspaceRecovery, + closeSessionWorkspaceRecovery, + relocateSessionWorkspace, + addSessionWorkspace, resolveWorkBoardTarget, prepareWorkBoardDraft, }, @@ -705,6 +800,9 @@ export function useTaskEntryController( usesDefaultHost: catalog.hosts.length === 0 || selectedProfileId === catalog.defaultProfileId, workspacePicker, + ...(sessionWorkspaceRecovery + ? { sessionWorkspaceRecovery } + : {}), canAddProject: Boolean( selectedHost && (selectedHost.capabilities.chooseClientDirectory || @@ -717,23 +815,28 @@ export function useTaskEntryController( addSelectedProject, newProjectDialog, openNewProject, + addSessionWorkspace, catalog.defaultProfileId, catalog.hosts.length, chooseProjectForProfile, closeDirectoryPicker, + closeSessionWorkspaceRecovery, directoryHost, projectPath, projectScopes, + openSessionWorkspaceRecovery, refreshCatalog, selectLocalProject, selectScopedProject, mutateScopedProject, service, resolveWorkBoardTarget, + relocateSessionWorkspace, prepareWorkBoardDraft, selectedHost, selectedHostProjection, selectedProfileId, + sessionWorkspaceRecovery, target, workspacePicker, ]); diff --git a/apps/desktop/src/renderer/features/task-entry/ports.ts b/apps/desktop/src/renderer/features/task-entry/ports.ts index 8d897d0ea4..85e84564c8 100644 --- a/apps/desktop/src/renderer/features/task-entry/ports.ts +++ b/apps/desktop/src/renderer/features/task-entry/ports.ts @@ -99,6 +99,13 @@ export type TaskEntryProjectMutationResult = | { readonly ok: true; readonly project: ProjectRecord } | { readonly ok: false; readonly reason: 'cancelled' }; +export type TaskEntrySessionWorkspaceResult = + | { readonly ok: true } + | { + readonly ok: false; + readonly reason: 'session_busy' | 'operation_conflict' | 'operation_unavailable' | 'not_found'; + }; + /** The minimum environment capability needed by Task Entry / Workspace. */ export interface TaskEntryCatalogService { getCatalog(): Promise; @@ -122,6 +129,14 @@ export interface TaskEntryCatalogService { restoreProject(host: TaskEntryHostRef, projectId: string): Promise; } +export interface TaskEntrySessionService { + relocateWorkspace( + sessionId: string, + projectId: string, + ): Promise; +} + export interface TaskEntryServices { readonly catalog: TaskEntryCatalogService; + readonly sessions: TaskEntrySessionService; } diff --git a/apps/desktop/src/renderer/features/task-entry/testing.ts b/apps/desktop/src/renderer/features/task-entry/testing.ts index 2a8dc19491..57d7be8f49 100644 --- a/apps/desktop/src/renderer/features/task-entry/testing.ts +++ b/apps/desktop/src/renderer/features/task-entry/testing.ts @@ -57,6 +57,9 @@ export function createFakeTaskEntryServices( archiveProject: async () => undefined, restoreProject: async () => undefined, }, + sessions: { + relocateWorkspace: async () => ({ ok: false, reason: 'operation_unavailable' }), + }, ...overrides, }; } diff --git a/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx b/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx index 80843d1b7d..c004942e62 100644 --- a/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx +++ b/apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx @@ -21,12 +21,15 @@ import { createContext, useCallback, useContext, + useEffect, useLayoutEffect, useMemo, + useState, useSyncExternalStore, type ReactNode, } from 'react'; import { useToast, type WorkspacePickerModel } from '@maka/ui'; +import type { RuntimeHostProfileKind } from '@maka/runtime-host/profile-kind'; import { useTaskEntryController, type TaskEntryController, @@ -47,7 +50,10 @@ interface TaskEntryOwner { export interface TaskEntryShellProjection { readonly commands: TaskEntryControllerCommands; - readonly selectors: Omit; + readonly selectors: Omit< + TaskEntryControllerSelectors, + 'workspacePicker' | 'sessionWorkspaceRecovery' + >; } export interface TaskEntryRootProps { @@ -75,6 +81,10 @@ const EMPTY_CONTROLLER: TaskEntryController = { addProject() {}, openNewProject() {}, async chooseProjectForProfile() {}, + openSessionWorkspaceRecovery() {}, + closeSessionWorkspaceRecovery() {}, + async relocateSessionWorkspace() { return false; }, + async addSessionWorkspace() { return false; }, resolveWorkBoardTarget: ( _item: Parameters[0], ): ReturnType => ({ @@ -130,6 +140,18 @@ function createTaskEntryOwner(): TaskEntryOwner & { openNewProject: () => current.commands.openNewProject(), chooseProjectForProfile: (profileId: string) => current.commands.chooseProjectForProfile(profileId), + openSessionWorkspaceRecovery: (sessionId: string) => + current.commands.openSessionWorkspaceRecovery(sessionId), + closeSessionWorkspaceRecovery: () => + current.commands.closeSessionWorkspaceRecovery(), + relocateSessionWorkspace: ( + input: Parameters[0], + ) => + current.commands.relocateSessionWorkspace(input), + addSessionWorkspace: ( + input: Parameters[0], + ) => + current.commands.addSessionWorkspace(input), resolveWorkBoardTarget: ( item: Parameters[0], ) => current.commands.resolveWorkBoardTarget(item), @@ -207,14 +229,27 @@ function sameProjectScopes( const selectShellSelectors = ( controller: TaskEntryController, -): Omit => { - const { workspacePicker: _workspacePicker, ...selectors } = controller.selectors; +): Omit< + TaskEntryControllerSelectors, + 'workspacePicker' | 'sessionWorkspaceRecovery' +> => { + const { + workspacePicker: _workspacePicker, + sessionWorkspaceRecovery: _sessionWorkspaceRecovery, + ...selectors + } = controller.selectors; return selectors; }; function sameShellSelectors( - previous: Omit, - next: Omit, + previous: Omit< + TaskEntryControllerSelectors, + 'workspacePicker' | 'sessionWorkspaceRecovery' + >, + next: Omit< + TaskEntryControllerSelectors, + 'workspacePicker' | 'sessionWorkspaceRecovery' + >, ): boolean { return ( sameTarget(previous.target, next.target) && @@ -231,6 +266,10 @@ function sameShellSelectors( const selectWorkspacePicker = (controller: TaskEntryController): WorkspacePickerModel => controller.selectors.workspacePicker; +const selectSessionWorkspaceRecovery = ( + controller: TaskEntryController, +): TaskEntryControllerSelectors['sessionWorkspaceRecovery'] => + controller.selectors.sessionWorkspaceRecovery; const selectHost = (controller: TaskEntryController): TaskEntryHostModel => controller.host; function sameHost(previous: TaskEntryHostModel, next: TaskEntryHostModel): boolean { @@ -301,22 +340,92 @@ function useTaskEntryOwner(): TaskEntryOwner { export function TaskEntryWorkspacePickerConsumer({ manageProjects, + activeSession, children, }: { readonly manageProjects: (profileId: string) => void; + readonly activeSession?: { + readonly id: string; + readonly profileId: string; + readonly runtimeHostId: string; + readonly projectId?: string | null; + readonly profileKind: RuntimeHostProfileKind; + }; readonly children: (workspacePicker: WorkspacePickerModel) => ReactNode; }) { const owner = useTaskEntryOwner(); const controllerPicker = useTaskEntrySelection(owner, selectWorkspacePicker); + const recovery = useTaskEntrySelection(owner, selectSessionWorkspaceRecovery); + const recoveryRequested = Boolean(activeSession && recovery?.sessionId === activeSession.id); + const [recoveryMenuOpen, setRecoveryMenuOpen] = useState(false); + useEffect(() => { + setRecoveryMenuOpen(recoveryRequested); + }, [recovery, recoveryRequested]); const workspacePicker = useMemo( - () => ({ - ...controllerPicker, - groups: controllerPicker.groups.map((group) => - group.onManage - ? { ...group, onManage: () => manageProjects(group.id) } - : group), - }), - [controllerPicker, manageProjects], + () => { + const defaultPicker: WorkspacePickerModel = { + ...controllerPicker, + groups: controllerPicker.groups.map((group) => + group.onManage + ? { ...group, onManage: () => manageProjects(group.id) } + : group), + }; + if (!activeSession || !recoveryRequested) return defaultPicker; + + const activeGroup = controllerPicker.groups.find( + (group) => group.hostId === activeSession.runtimeHostId, + ); + const activeProject = activeGroup?.projects.find( + (project) => + project.id === activeSession.projectId || + project.aliases?.includes(activeSession.projectId ?? ''), + ); + return { + ...defaultPicker, + label: activeProject?.name ?? activeGroup?.label, + branch: null, + showForActiveSession: true, + isMenuOpen: recoveryMenuOpen, + // Closing the menu may hand off to its New project dialog. Keep the + // Session recovery context (and picker) alive until relocation succeeds. + onOpenChange: setRecoveryMenuOpen, + selectedGroupId: activeGroup?.id, + groups: activeGroup + ? [{ + ...activeGroup, + projects: activeGroup.projects.filter((project) => project.available), + selectedProjectId: activeSession.projectId ?? null, + onSelectProject: (projectId: string) => void owner.commands.relocateSessionWorkspace({ + sessionId: activeSession.id, + profileId: activeSession.profileId, + projectId, + }), + onAdd: + activeSession.profileKind === 'local' && activeGroup.hostId + ? (name: string) => void owner.commands.addSessionWorkspace({ + sessionId: activeSession.id, + profileId: activeSession.profileId, + host: { profileId: activeGroup.id, hostId: activeGroup.hostId! }, + name, + }) + : undefined, + onRelink: undefined, + onSelectNoProject: undefined, + onManage: activeGroup.onManage + ? () => manageProjects(activeGroup.id) + : undefined, + }] + : [], + }; + }, + [ + activeSession, + controllerPicker, + manageProjects, + owner, + recoveryMenuOpen, + recoveryRequested, + ], ); return children(workspacePicker); } diff --git a/apps/desktop/src/renderer/platform/desktop/create-task-entry-services.ts b/apps/desktop/src/renderer/platform/desktop/create-task-entry-services.ts index 0e5ca7e8a7..f8d7d40f68 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-task-entry-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-task-entry-services.ts @@ -20,7 +20,7 @@ import type { MakaBridge } from '../../../preload/bridge-contract.js'; import type { TaskEntryServices } from '../../features/task-entry'; -export type DesktopTaskEntryBridge = Pick; +export type DesktopTaskEntryBridge = Pick; /** The only Desktop-to-Task Entry adapter. */ export function createDesktopTaskEntryServices( @@ -39,5 +39,13 @@ export function createDesktopTaskEntryServices( await bridge.projects.restore(projectId, host); }, }, + sessions: { + async relocateWorkspace(sessionId, projectId) { + const result = await bridge.sessions.moveToProject(sessionId, projectId); + return result.ok + ? { ok: true as const } + : { ok: false as const, reason: result.code }; + }, + }, }; } diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index dcef7b5fb0..7895f086f1 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -2394,7 +2394,8 @@ export const Composer = forwardRef< the open menu next to the trigger rather than portaling it, so the palette rebinding and the pinned-footer rules attach here. */} - {!props.activeSession && props.workspacePicker ? ( + {props.workspacePicker && + (!props.activeSession || props.workspacePicker.showForActiveSession) ? (
diff --git a/packages/ui/src/workspace-picker.tsx b/packages/ui/src/workspace-picker.tsx index da603e9e60..e7bb33d4dd 100644 --- a/packages/ui/src/workspace-picker.tsx +++ b/packages/ui/src/workspace-picker.tsx @@ -28,6 +28,8 @@ import { NewProjectDialog } from './new-project-dialog.js'; export interface WorkspacePickerGroup { id: string; + /** Runtime Host identity for session-scoped recovery actions. */ + hostId?: string; label: string; status?: string; disabled?: boolean; @@ -50,6 +52,11 @@ export interface WorkspacePickerModel { hostBadge?: string; branch?: string | null; pending?: boolean; + /** Keep the picker mounted when it is repairing an existing Session. */ + showForActiveSession?: boolean; + /** Optional controlled open state used by an external recovery action. */ + isMenuOpen?: boolean; + onOpenChange?(isOpen: boolean): void; selectedGroupId?: string; groups: readonly WorkspacePickerGroup[]; retry?: { label: string; onClick(): void }; @@ -71,6 +78,8 @@ export function WorkspacePicker({ workspacePicker: picker }: {