From 99c29d5753aab7379e885976b4582f9391938279 Mon Sep 17 00:00:00 2001 From: liuzhaochen03 Date: Mon, 21 Sep 2026 14:28:08 +0800 Subject: [PATCH 1/4] fix(desktop): restore archived projects when re-adding directories Offer to restore archived projects from task entry, project settings, and remote directory registration. Preserve project naming, show loading state, and report add, restore, and refresh failures. Remove the unused app-shell add action and route runtime-host catalog failures through redacted diagnostics. Add regression coverage for archived-project restoration and error handling. Generated-by: Codex --- .../__tests__/project-add-restore.test.ts | 285 ++++++++++++++++++ .../project-management-service.test.ts | 34 +++ .../__tests__/task-entry-controller.test.ts | 92 +++++- .../src/main/project-management-service.ts | 6 +- apps/desktop/src/preload/bridge-contract.d.ts | 9 +- apps/desktop/src/preload/preload.ts | 11 +- .../controller/use-task-entry-controller.ts | 49 ++- .../src/renderer/features/task-entry/ports.ts | 4 +- .../renderer/features/task-entry/testing.ts | 1 + .../locales/settings-projects-copy.ts | 16 + .../src/renderer/locales/shell-copy.ts | 16 + .../remote-project-directory-dialog.tsx | 17 +- .../settings/projects-settings-page.tsx | 36 ++- .../src/server/project-catalog-coordinator.ts | 2 + .../src/server/skill-catalog-coordinator.ts | 2 + 15 files changed, 547 insertions(+), 33 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/project-add-restore.test.ts diff --git a/apps/desktop/src/main/__tests__/project-add-restore.test.ts b/apps/desktop/src/main/__tests__/project-add-restore.test.ts new file mode 100644 index 00000000000..bb2febcec5d --- /dev/null +++ b/apps/desktop/src/main/__tests__/project-add-restore.test.ts @@ -0,0 +1,285 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { after, afterEach, before, test } from 'node:test'; +import { pathToFileURL } from 'node:url'; +import { build } from 'esbuild'; +import { parseHTML } from 'linkedom'; +import { act, createElement, type ComponentType, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui'; +import type { ProjectRecord } from '@maka/core/project'; +import type { DesktopRuntimeHostRef } from '../../preload/bridge-contract.js'; + +interface RenderModules { + RemoteProjectDirectoryDialog: ComponentType<{ + host?: DesktopRuntimeHostRef; + onClose(): void; + onRegistered(project: ProjectRecord, host: DesktopRuntimeHostRef): void; + }>; + SettingsFixture: ComponentType; +} +let components: RenderModules; +let bundleDirectory: string; +let mountedRoot: Root | undefined; +let frames: FrameRequestCallback[] = []; +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + HTMLElement: globalThis.HTMLElement, + HTMLIFrameElement: globalThis.HTMLIFrameElement, + Node: globalThis.Node, + Event: globalThis.Event, + CSS: globalThis.CSS, + matchMedia: globalThis.matchMedia, + getComputedStyle: globalThis.getComputedStyle, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT, +}; + +before(async () => { + const repoRoot = resolve(import.meta.dirname, '../../../../..'); + bundleDirectory = await mkdtemp(resolve(repoRoot, 'apps/desktop/dist/main/__tests__/project-restore-')); + const outfile = resolve(bundleDirectory, 'components.mjs'); + await build({ + stdin: { + contents: ` + import { createElement } from 'react'; + import { ProjectsSettingsPage } from './settings/projects-settings-page'; + import { RuntimeHostSettingsTarget } from './settings/runtime-host-settings-target'; + export { RemoteProjectDirectoryDialog } from './remote-project-directory-dialog'; + export function SettingsFixture() { + return createElement(RuntimeHostSettingsTarget, { + host: { profileId: 'remote', hostId: 'host-remote' }, + children: createElement(ProjectsSettingsPage, { + settings: { projects: {} }, runtimeHostStatus: 'ready', runtimeHostTargetVerified: true, + onUpdate: async () => {}, onRetryRuntimeHost: async () => {}, onRemoteHostAdded() {}, + }), + }); + } + `, + resolveDir: resolve(repoRoot, 'apps/desktop/src/renderer'), + }, + outfile, bundle: true, packages: 'external', loader: { '.svg': 'dataurl' }, + platform: 'node', format: 'esm', jsx: 'automatic', target: 'node20', logLevel: 'silent', + plugins: [{ name: 'omit-unrelated-host-profile-settings', setup(builder) { + builder.onResolve({ filter: /runtime-host-profiles-section/ }, () => ({ path: 'profiles', namespace: 'fixture' })); + builder.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({ contents: 'export function RuntimeHostProfilesSection() { return null; }' })); + } }], + }); + components = await import(pathToFileURL(outfile).href) as RenderModules; +}); +afterEach(async () => { + try { if (mountedRoot) await act(() => mountedRoot?.unmount()); } + finally { mountedRoot = undefined; frames = []; Object.assign(globalThis, originalGlobals); } +}); +after(async () => { if (bundleDirectory) await rm(bundleDirectory, { recursive: true, force: true }); }); + +const host = { profileId: 'remote', hostId: 'host-remote' }; +const restored: ProjectRecord = { id: 'p', name: 'Project', locations: [], available: true }; +const archived: ProjectRecord = { ...restored, archivedAt: 1 }; +function bridge(overrides: Record = {}) { + Object.assign(window, { maka: { + projects: { + getDirectoryRoots: async () => [{ id: 'root', name: 'Root' }], + listDirectory: async () => [], registerDirectory: async () => archived, + restore: async () => restored, + getSnapshot: async () => ({ projects: [], capabilities: { chooseClientDirectory: true } }), + subscribeChanges: () => () => {}, + ...overrides, + }, + app: { info: async () => ({}) }, + runtimeHostProfiles: { subscribeChanges: () => () => {} }, + } }); +} +async function flushFrames() { + for (let index = 0; index < 5; index++) { + const pending = frames.splice(0); + await act(async () => { for (const callback of pending) callback(0); }); + } +} +async function click(label: string) { + const button = [...document.querySelectorAll('button')].filter( + (candidate) => candidate.textContent === label || candidate.getAttribute('aria-label') === label, + ).at(-1); + assert.ok(button, `missing button ${label}: ${document.body.textContent}`); + await act(async () => button.dispatchEvent(new window.Event('click', { bubbles: true }))); + await flushFrames(); +} + +async function submitNamedProject() { + await click('New project'); + const input = document.querySelector('input'); + assert.ok(input); + await act(async () => { + input.value = 'Project'; + const propsKey = Object.keys(input).find((key) => key.startsWith('__reactProps$')); + assert.ok(propsKey); + const props = (input as unknown as Record)[propsKey] as { + onChange: (event: { target: HTMLInputElement; defaultPrevented: boolean }) => void; + }; + props.onChange({ target: input, defaultPrevented: false }); + }); + const form = document.querySelector('#maka-new-project-form'); + assert.ok(form); + await act(async () => form.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true }))); + await flushFrames(); +} + +for (const outcome of ['restore', 'cancel', 'failure', 'normal', 'stale'] as const) { + test(`remote directory registration: ${outcome}`, async () => { + const harness = installRenderer(); + const accepted: ProjectRecord[] = []; + const restores: unknown[][] = []; + bridge({ + registerDirectory: async () => outcome === 'normal' ? restored : archived, + restore: async (...args: unknown[]) => { + restores.push(args); + if (outcome === 'failure') throw new Error('restore failed'); + return restored; + }, + }); + const render = (target: DesktopRuntimeHostRef | undefined = host) => harness.render( + createElement(components.RemoteProjectDirectoryDialog, { + host: target, onClose() {}, onRegistered(project) { accepted.push(project); }, + }), + ); + await render(); + await click('Add this folder'); + if (outcome === 'normal') { + assert.deepEqual(accepted, [restored]); + assert.equal(restores.length, 0); + return; + } + assert.equal(accepted.length, 0, 'must not accept an archived project before confirmation'); + assert.match(document.body.textContent, /Project archived/); + if (outcome === 'stale') await render({ profileId: 'another', hostId: 'another-host' }); + if (outcome === 'cancel') { + const dialog = [...document.querySelectorAll('dialog')].find(el => el.textContent.includes('Project archived')); + assert.ok(dialog); + const cancel = [...dialog.querySelectorAll('button')].find(el => el.textContent === 'Cancel'); + assert.ok(cancel); + await act(async () => cancel.dispatchEvent(new window.Event('click', { bubbles: true }))); + await flushFrames(); + } else await click('Restore'); + if (outcome === 'restore') { + assert.deepEqual(restores, [['p', host]]); + assert.deepEqual(accepted, [restored]); + } else { + assert.equal(accepted.length, 0); + assert.equal(restores.length, outcome === 'failure' ? 1 : 0); + if (outcome === 'failure') assert.ok(document.querySelector('.remoteProjectDirectoryError[role="alert"]')); + const add = [...document.querySelectorAll('button')].find(el => el.textContent === 'Add this folder'); + assert.ok(add); + assert.equal(add.hasAttribute('disabled'), false, 'registration can be retried'); + } + }); +} + +for (const failure of ['add', 'restore'] as const) { + test(`Settings reports ${failure} failure and releases Add`, async () => { + const harness = installRenderer(); + bridge({ + add: async () => { + if (failure === 'add') throw new Error('add failed'); + return { ok: false, reason: 'archived', projectId: 'p' }; + }, + restore: async () => { throw new Error('restore failed'); }, + }); + await harness.render(createElement(components.SettingsFixture)); + await submitNamedProject(); + if (failure === 'restore') await click('Restore'); + assert.match(document.body.textContent, /Action failed/); + const add = [...document.querySelectorAll('button')].find(el => el.textContent === 'New project'); + assert.ok(add); + assert.notEqual(add.getAttribute('aria-busy'), 'true'); + assert.equal(add.hasAttribute('disabled'), false); + }); +} +for (const outcome of ['restore', 'cancel', 'refresh-failure'] as const) { + test(`Settings archived Add: ${outcome}`, async () => { + const harness = installRenderer(); + let restores = 0; + let snapshots = 0; + bridge({ + add: async () => ({ ok: false, reason: 'archived', projectId: 'p' }), + restore: async () => { restores++; return restored; }, + getSnapshot: async () => { + snapshots++; + if (snapshots > 1 && outcome === 'refresh-failure') throw new Error('refresh failed'); + return { projects: [], capabilities: { chooseClientDirectory: true } }; + }, + }); + await harness.render(createElement(components.SettingsFixture)); + await submitNamedProject(); + assert.equal(restores, 0); + await click(outcome === 'cancel' ? 'Cancel' : 'Restore'); + assert.equal(restores, outcome === 'cancel' ? 0 : 1); + assert.ok(snapshots > 1); + if (outcome === 'refresh-failure') assert.match(document.body.textContent, /Action failed/); + }); +} + +function installRenderer() { + const { document, window } = parseHTML('
'); + const matchMedia = (media: string) => ({ + matches: false, media, onchange: null, + addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {}, + dispatchEvent: () => false, + }); + const getComputedStyle = () => ({ + direction: 'ltr', writingMode: 'horizontal-tb', getPropertyValue: () => '', + }) as unknown as CSSStyleDeclaration; + Object.assign(window, { matchMedia, getComputedStyle, scrollTo() {} }); + Object.assign(window.HTMLElement.prototype, { + select() {}, + showModal(this: HTMLElement) { this.setAttribute('open', ''); }, + close(this: HTMLElement) { this.removeAttribute('open'); }, + }); + Object.assign(globalThis, { + document, window, matchMedia, getComputedStyle, + HTMLElement: window.HTMLElement, + HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {}, + Event: window.Event, Node: window.Node, CSS: { escape: (value: string) => value }, + requestAnimationFrame: (callback: FrameRequestCallback) => { frames.push(callback); return frames.length; }, cancelAnimationFrame: () => {}, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.getElementById('root'); + assert.ok(container); + const root = createRoot(container); + mountedRoot = root; + return { + document, + async render(children: ReactNode) { + await act(async () => root.render(createElement(LocaleProvider, { + locale: 'en', + children: createElement(AstryxLocaleProvider, { + children: createElement(ToastProvider, { + children, + }), + }), + }))); + }, + }; +} diff --git a/apps/desktop/src/main/__tests__/project-management-service.test.ts b/apps/desktop/src/main/__tests__/project-management-service.test.ts index b63aee5a6b3..272d5ef27d3 100644 --- a/apps/desktop/src/main/__tests__/project-management-service.test.ts +++ b/apps/desktop/src/main/__tests__/project-management-service.test.ts @@ -141,6 +141,40 @@ const verifyExactRepositorySelection = async (t: TestContext) => { ); }; test('add preserves the exact selected directory inside an existing repository', verifyExactRepositorySelection); + +test('re-adding an archived project reports the archived project instead of failing', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-archived-add-')); + const projectPath = join(base, 'archived-project'); + await mkdir(projectPath); + const selectedPaths: string[] = []; + const catalog = createProjectCatalog(join(base, 'storage'), { + now: () => 1_000, + createId: () => 'project-1', + }); + const service = createProjectManagementService({ + capabilities: LOCAL_CAPABILITIES, + catalog: managementCatalog(catalog), + chooseDirectory: async () => projectPath, + selection: { + currentSelection: async () => ({ projectId: undefined, path: base }), + setSelection: (_projectId, path) => selectedPaths.push(path), + }, + }); + + try { + const first = await service.add(); + assert.equal(first.ok, true); + await service.archive('project-1'); + + const second = await service.add(); + assert.deepEqual(second, { ok: false, reason: 'archived', projectId: 'project-1' }); + assert.equal(selectedPaths.length, 1); + } finally { + catalog.close(); + await rm(base, { recursive: true, force: true }); + } +}); + test('can register a draft Project without changing the Host selection', async () => { let selected = false; const service = createProjectManagementService({ 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 3893bcb26f8..6a9504b2bac 100644 --- a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts +++ b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts @@ -21,7 +21,7 @@ import { deferred } from '@maka/core/test-only/async-primitives'; import { strict as assert } from 'node:assert'; import { afterEach, describe, it } from 'node:test'; import { act, createElement } from 'react'; -import { LocaleProvider } from '@maka/ui'; +import { LocaleProvider, ToastProvider } from '@maka/ui'; import { getShellCopy } from '../../renderer/locales/shell-copy.js'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { @@ -99,10 +99,14 @@ function catalog(host: TaskEntryHost = readyHost()): TaskEntryCatalog { } let latestController: TaskEntryController | undefined; -function ControllerProbe(props: { reportError(error: unknown): void }) { +function ControllerProbe(props: { + reportError(error: unknown): void; + confirm?(input: { title: string }): Promise; +}) { latestController = useTaskEntryController({ reportError: props.reportError, manageProjects() {}, + ...(props.confirm ? { confirm: props.confirm } : {}), }); return null; } @@ -116,17 +120,23 @@ function renderController( root: ReturnType['root'], services: TaskEntryServices, errors: unknown[] = [], + confirm?: (input: { title: string }) => Promise, ) { root.render( createElement(LocaleProvider, { locale: 'en', children: createElement( + ToastProvider, + null, + createElement( TaskEntryServicesProvider, { services }, createElement(ControllerProbe, { reportError: (error: unknown) => errors.push(error), + confirm, }), ), + ), }), ); } @@ -329,6 +339,84 @@ describe('useTaskEntryController', () => { assert.equal(controller().selectors.workspacePicker.pending, false); }); + it('prompts to restore an archived Project and selects it after confirmation', async () => { + const { root } = installReactRenderer(); + let reads = 0; + let restoreCalls = 0; + const refreshedHost = readyHost({ + projects: [project('project-a'), project('project-b')], + selectedProjectId: 'project-a', + }); + const services = createFakeTaskEntryServices({ + catalog: { + ...createFakeTaskEntryServices().catalog, + getCatalog: async () => + catalog(++reads === 1 ? readyHost() : refreshedHost), + addProject: async () => ({ + ok: false as const, + reason: 'archived' as const, + projectId: 'project-a', + }), + restoreProject: async () => { + restoreCalls += 1; + return { ok: true as const, project: project('project-a') }; + }, + }, + }); + + await act(async () => renderController(root, services, [], async () => true)); + await act(async () => { + controller().commands.addProject(); + await Promise.resolve(); + }); + await act(async () => {}); + + assert.equal(restoreCalls, 1); + assert.equal(controller().selectors.target?.projectId, 'project-a'); + }); + + it('reports restore failure and releases the pending state so the user can retry', async () => { + const { root } = installReactRenderer(); + const restoration = deferred<{ ok: true; project: ReturnType }>(); + const errors: unknown[] = []; + let restoreCalls = 0; + const services = createFakeTaskEntryServices({ + catalog: { + ...createFakeTaskEntryServices().catalog, + getCatalog: async () => catalog(), + addProject: async () => ({ + ok: false, + reason: 'archived', + projectId: 'project-b', + }), + restoreProject: async () => { + restoreCalls += 1; + return restoreCalls === 1 + ? restoration.promise + : { ok: false, reason: 'cancelled' }; + }, + }, + }); + + await act(async () => renderController(root, services, errors, async () => true)); + await act(async () => controller().commands.addProject()); + assert.equal(controller().selectors.workspacePicker.pending, true); + + await act(async () => restoration.reject(new Error('restore failed'))); + + assert.deepEqual(errors, [{ + title: 'Could not select working directory', + description: 'The project path is temporarily unavailable. Try again later.', + profileId: 'local', + }]); + assert.equal(controller().selectors.target?.projectId, 'project-a'); + assert.equal(controller().selectors.workspacePicker.pending, false); + + await act(async () => controller().commands.addProject()); + assert.equal(restoreCalls, 2); + assert.equal(errors.length, 1); + }); + it('deduplicates relink requests and selects the returned Project before refreshing', async () => { const { root } = installReactRenderer(); const relinked = deferred<{ diff --git a/apps/desktop/src/main/project-management-service.ts b/apps/desktop/src/main/project-management-service.ts index 7f12efd7a91..08187e0bb50 100644 --- a/apps/desktop/src/main/project-management-service.ts +++ b/apps/desktop/src/main/project-management-service.ts @@ -31,7 +31,8 @@ type DirectoryActionResult = | { ok: false; reason: 'cancelled' }; type SelectedDirectoryActionResult = | { ok: true; project: ProjectRecord; path: string } - | { ok: false; reason: 'cancelled' }; + | { ok: false; reason: 'cancelled' } + | { ok: false; reason: 'archived'; projectId: string }; export interface ProjectManagementService { current(): Promise; @@ -129,6 +130,9 @@ export function createProjectManagementService(deps: { const path = await deps.chooseDirectory(); if (!path) return { ok: false, reason: 'cancelled' }; const registered = await deps.catalog.register(path); + if (registered.archivedAt !== undefined) { + return { ok: false, reason: 'archived', projectId: registered.id }; + } // Rename before selecting, so the value the caller reads back (and the // one the selection records) is the name the user typed, not the folder // basename it was registered under. A blank name is not a name: it leaves diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index e5bd81c43ea..d07fe5fc6e9 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1013,6 +1013,11 @@ export interface MakaBridge { getCatalog(): Promise; subscribeChanges(handler: () => void): () => void; addProject(host: DesktopNewTaskHostRef, name?: string): Promise< + | { ok: true; project: ProjectRecord } + | { ok: false; reason: 'cancelled' } + | { ok: false; reason: 'archived'; projectId: string } + >; + restoreProject(host: DesktopNewTaskHostRef, projectId: string): Promise< { ok: true; project: ProjectRecord } | { ok: false; reason: 'cancelled' } >; relinkProject(host: DesktopNewTaskHostRef, projectId: string): Promise< @@ -1492,7 +1497,9 @@ export interface MakaBridge { * the caller never sees the folder-derived placeholder. */ add(host?: DesktopRuntimeHostRef, options?: { readonly name?: string }): Promise< - { ok: true; project: ProjectRecord; path: string } | { ok: false; reason: 'cancelled' } + | { ok: true; project: ProjectRecord; path: string } + | { ok: false; reason: 'cancelled' } + | { ok: false; reason: 'archived'; projectId: string } >; getDirectoryRoots(host: DesktopRuntimeHostRef): Promise; listDirectory( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 50af09570ec..0239f3caf3d 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1984,9 +1984,18 @@ const makaBridge = { { select: false, ...(name === undefined ? {} : { name }) }, ) as | { ok: true; project: ProjectRecord; path: string } - | { ok: false; reason: 'cancelled' }; + | { ok: false; reason: 'cancelled' } + | { ok: false; reason: 'archived'; projectId: string }; return result.ok ? { ok: true as const, project: result.project } : result; }, + async restoreProject(host: DesktopNewTaskHostRef, projectId: string) { + const project = await ipcRenderer.invoke( + 'projects:restore', + await runtimeHostScope(host), + projectId, + ) as ProjectRecord; + return { ok: true as const, project }; + }, async relinkProject(host: DesktopNewTaskHostRef, projectId: string) { return invokeWhenReady( 'projects:relink', 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 33bf613ccb4..f66b2e089a1 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 @@ -33,6 +33,7 @@ import { import { getConversationCopy, type WorkspacePickerModel, + useToast, useUiLocale, } from '@maka/ui'; import { getShellCopy, localizedShellErrorMessage } from '../../../locales/shell-copy.js'; @@ -63,6 +64,13 @@ import { runtimeHostProjectKey } from '../../../application/contracts/runtime-ho export interface UseTaskEntryControllerInput { reportError(error: TaskEntryError): void; manageProjects(profileId: string): void; + /** Defaults to the app toast confirm dialog; injected in tests. */ + confirm?(input: { + title: string; + description?: string; + confirmLabel?: string; + cancelLabel?: string; + }): Promise; } export interface TaskEntryControllerSelectors { @@ -170,6 +178,7 @@ export function useTaskEntryController( input: UseTaskEntryControllerInput, ): TaskEntryController { const locale = useUiLocale(); + const toast = useToast(); const copy = getShellCopy(locale).projectActions; const sessionMoveCopy = getShellCopy(locale).sessionRowActions; const conversationCopy = getConversationCopy(locale).workspace; @@ -406,22 +415,26 @@ export function useTaskEntryController( projectMutationPendingRef.current = true; setPending(true); try { - let result: TaskEntryProjectMutationResult; - try { - result = await service.addProject( - { - profileId: host.profile.id, - hostId: host.hostId, - }, - name, - ); - } catch (cause) { - reportError({ - title: copy.selectDirectoryFailedTitle, - description: localizedShellErrorMessage(cause, copy.readPathFailedFallback, locale), + let result: TaskEntryProjectMutationResult = await service.addProject( + { profileId: host.profile.id, + hostId: host.hostId, + }, + name, + ); + if (!result.ok && result.reason === 'archived') { + const confirmed = await (input.confirm ?? toast.confirm)({ + title: copy.archivedProjectTitle, + description: copy.archivedProjectDescription, + confirmLabel: copy.archivedProjectRestore, + cancelLabel: copy.archivedProjectCancel, }); - return; + if (!confirmed) return; + result = await service.restoreProject( + { profileId: host.profile.id, hostId: host.hostId }, + result.projectId, + ); + if (!result.ok) return; } if (!result.ok) return; setSelectedProfileId(host.profile.id); @@ -429,11 +442,17 @@ export function useTaskEntryController( new Map(current).set(host.profile.id, result.project.id), ); await refreshAfterProjectMutation(host.profile.id); + } catch (cause) { + reportError({ + title: copy.selectDirectoryFailedTitle, + description: localizedShellErrorMessage(cause, copy.readPathFailedFallback, locale), + profileId: host.profile.id, + }); } finally { projectMutationPendingRef.current = false; setPending(false); } - }, [copy.readPathFailedFallback, copy.selectDirectoryFailedTitle, locale, refreshAfterProjectMutation, reportError, service]); + }, [copy.archivedProjectCancel, copy.archivedProjectDescription, copy.archivedProjectRestore, copy.archivedProjectTitle, copy.readPathFailedFallback, copy.selectDirectoryFailedTitle, locale, refreshAfterProjectMutation, reportError, service, toast]); const chooseProjectForProfile = useCallback(async (profileId: string): Promise => { let next: TaskEntryCatalog | undefined; diff --git a/apps/desktop/src/renderer/features/task-entry/ports.ts b/apps/desktop/src/renderer/features/task-entry/ports.ts index 6a311d0e1bc..66913e4a142 100644 --- a/apps/desktop/src/renderer/features/task-entry/ports.ts +++ b/apps/desktop/src/renderer/features/task-entry/ports.ts @@ -100,7 +100,8 @@ export interface TaskEntryCatalog { export type TaskEntryProjectMutationResult = | { readonly ok: true; readonly project: ProjectRecord } - | { readonly ok: false; readonly reason: 'cancelled' }; + | { readonly ok: false; readonly reason: 'cancelled' } + | { readonly ok: false; readonly reason: 'archived'; readonly projectId: string }; export type TaskEntrySessionWorkspaceResult = | { readonly ok: true } @@ -114,6 +115,7 @@ export interface TaskEntryCatalogService { getCatalog(): Promise; subscribeChanges(handler: () => void): TaskEntryUnsubscribe; addProject(host: TaskEntryHostRef, name?: string): Promise; + restoreProject(host: TaskEntryHostRef, projectId: string): Promise; relinkProject( host: TaskEntryHostRef, projectId: string, diff --git a/apps/desktop/src/renderer/features/task-entry/testing.ts b/apps/desktop/src/renderer/features/task-entry/testing.ts index def18db03fd..768acf2e35f 100644 --- a/apps/desktop/src/renderer/features/task-entry/testing.ts +++ b/apps/desktop/src/renderer/features/task-entry/testing.ts @@ -54,6 +54,7 @@ export function createFakeTaskEntryServices( getCatalog: async () => ({ defaultProfileId: 'local', hosts: [] }), subscribeChanges: noopSubscription, addProject: async () => ({ ok: false, reason: 'cancelled' }), + restoreProject: async () => ({ ok: false, reason: 'cancelled' }), relinkProject: async () => ({ ok: false, reason: 'cancelled' }), renameProject: async () => undefined, archiveProject: async () => undefined, diff --git a/apps/desktop/src/renderer/locales/settings-projects-copy.ts b/apps/desktop/src/renderer/locales/settings-projects-copy.ts index 28520bceb2d..9f6921582ea 100644 --- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts @@ -284,6 +284,10 @@ export type SettingsProjectsCopy = { sectionHelp: string; projectsTitle: string; addProject: string; + archivedProjectTitle: string; + archivedProjectDescription: string; + archivedProjectRestore: string; + archivedProjectCancel: string; defaultBadge: string; setDefault: string; setDefaultTitle: string; @@ -643,6 +647,10 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { sectionHelp: '新任务默认打开此项目;未设置时沿用上次使用的项目。任何任务都能在输入框旁临时切换。', projectsTitle: '项目', addProject: '新建项目', + archivedProjectTitle: '项目已归档', + archivedProjectDescription: '该项目已归档,是否需要恢复?', + archivedProjectRestore: '恢复', + archivedProjectCancel: '取消', defaultBadge: '默认', setDefault: '设为默认', setDefaultTitle: '新任务默认打开这个项目', @@ -982,6 +990,10 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { sectionHelp: '新任務預設開啟此專案;未設定時沿用上次使用的專案。任何任務都能在輸入框旁臨時切換。', projectsTitle: '專案', addProject: '新增專案', + archivedProjectTitle: '專案已歸檔', + archivedProjectDescription: '該專案已歸檔,是否需要恢復?', + archivedProjectRestore: '恢復', + archivedProjectCancel: '取消', defaultBadge: '預設', setDefault: '設為預設', setDefaultTitle: '新任務預設開啟這個專案', @@ -1339,6 +1351,10 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { 'New tasks open in the default project; without one, they reuse the project you last used. You can switch any task to a different project next to the input box.', projectsTitle: 'Projects', addProject: 'New project', + archivedProjectTitle: 'Project archived', + archivedProjectDescription: 'This project is archived. Restore it?', + archivedProjectRestore: 'Restore', + archivedProjectCancel: 'Cancel', defaultBadge: 'Default', setDefault: 'Set as default', setDefaultTitle: 'Open new tasks in this project', diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index bbecc4bc4d2..8352907415f 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -192,6 +192,10 @@ type ShellCopy = { projectUpdateFailedFallback: string; catalogUnavailable: string; retryCatalog: string; + archivedProjectTitle: string; + archivedProjectDescription: string; + archivedProjectRestore: string; + archivedProjectCancel: string; remoteDirectoryTitle(host: string): string; remoteDirectoryBreadcrumbs: string; remoteDirectoryHome: string; @@ -790,6 +794,10 @@ const SHELL_COPY_BY_LOCALE = { projectUpdateFailedFallback: '暂时无法更新项目,请稍后重试。', catalogUnavailable: 'Runtime Host 暂时不可用', retryCatalog: '重试加载', + archivedProjectTitle: '项目已归档', + archivedProjectDescription: '该项目已归档,是否需要恢复?', + archivedProjectRestore: '恢复', + archivedProjectCancel: '取消', remoteDirectoryTitle: (host: string) => `在 ${host} 上添加项目`, remoteDirectoryBreadcrumbs: '当前文件夹', remoteDirectoryHome: '主目录', @@ -1314,6 +1322,10 @@ const SHELL_COPY_BY_LOCALE = { projectUpdateFailedFallback: '暫時無法更新專案,請稍後重試。', catalogUnavailable: 'Runtime Host 暫時不可用', retryCatalog: '重試載入', + archivedProjectTitle: '專案已歸檔', + archivedProjectDescription: '該專案已歸檔,是否需要恢復?', + archivedProjectRestore: '恢復', + archivedProjectCancel: '取消', remoteDirectoryTitle: (host: string) => `在 ${host} 上新增專案`, remoteDirectoryBreadcrumbs: '目前資料夾', remoteDirectoryHome: '主目錄', @@ -1840,6 +1852,10 @@ const SHELL_COPY_BY_LOCALE = { projectUpdateFailedFallback: 'The project could not be updated. Try again later.', catalogUnavailable: 'Runtime Hosts unavailable', retryCatalog: 'Retry loading', + archivedProjectTitle: 'Project archived', + archivedProjectDescription: 'This project is archived. Restore it?', + archivedProjectRestore: 'Restore', + archivedProjectCancel: 'Cancel', remoteDirectoryTitle: (host: string) => `Add a project on ${host}`, remoteDirectoryBreadcrumbs: 'Current folder', remoteDirectoryHome: 'Home', diff --git a/apps/desktop/src/renderer/remote-project-directory-dialog.tsx b/apps/desktop/src/renderer/remote-project-directory-dialog.tsx index 2209482e0f1..37c19d84162 100644 --- a/apps/desktop/src/renderer/remote-project-directory-dialog.tsx +++ b/apps/desktop/src/renderer/remote-project-directory-dialog.tsx @@ -25,7 +25,7 @@ import { DropdownMenu, DropdownMenuItem } from '@astryxdesign/core/DropdownMenu' import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; import { HStack } from '@astryxdesign/core/Stack'; import { Text } from '@astryxdesign/core/Text'; -import { useUiLocale } from '@maka/ui'; +import { useToast, useUiLocale } from '@maka/ui'; import { reportUnexpectedError } from './application/contracts/operation-diagnostics.js'; import { Check, Eye, EyeOff, FolderOpen } from '@maka/ui/icons'; import type { @@ -53,6 +53,7 @@ export function RemoteProjectDirectoryDialog(props: { onRegistered(project: ProjectRecord, host: DesktopRuntimeHostRef): void; }) { const locale = useUiLocale(); + const toast = useToast(); const copy = getShellCopy(locale).projectActions; const [roots, setRoots] = useState([]); const [root, setRoot] = useState(); @@ -97,6 +98,7 @@ export function RemoteProjectDirectoryDialog(props: { const sequence = ++request.current; lastLoad.current = target; if (target.kind === 'initial') { + setRegistering(false); setRoots([]); setRoot(undefined); setSegments([]); @@ -162,11 +164,22 @@ export function RemoteProjectDirectoryDialog(props: { setRegistering(true); setError(undefined); try { - const project = await window.maka.projects.registerDirectory({ + let project = await window.maka.projects.registerDirectory({ rootId: root.id, segments, }, host); if (request.current !== sequence) return; + if (project.archivedAt !== undefined) { + const confirmed = await toast.confirm({ + title: copy.archivedProjectTitle, + description: copy.archivedProjectDescription, + confirmLabel: copy.archivedProjectRestore, + cancelLabel: copy.archivedProjectCancel, + }); + if (!confirmed || request.current !== sequence) return; + project = await window.maka.projects.restore(project.id, host); + if (request.current !== sequence) return; + } props.onRegistered(project, host); } catch (cause) { if (request.current !== sequence) return; diff --git a/apps/desktop/src/renderer/settings/projects-settings-page.tsx b/apps/desktop/src/renderer/settings/projects-settings-page.tsx index e84c670686a..140da485998 100644 --- a/apps/desktop/src/renderer/settings/projects-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/projects-settings-page.tsx @@ -92,6 +92,7 @@ export function ProjectsSettingsPage(props: { const [homePath, setHomePath] = useState(undefined); const [renamingId, setRenamingId] = useState(null); const [draftName, setDraftName] = useState(''); + const [adding, setAdding] = useState(false); const [projectDialog, setProjectDialog] = useState<'directory' | 'new' | null>(null); const directoryPickerTriggerRef = useRef(null); const reloadGeneration = useRef(0); @@ -111,14 +112,28 @@ export function ProjectsSettingsPage(props: { * call still owns the folder picker, so a cancelled picker simply leaves the * list unchanged; the name rides along and the project is registered under it. */ - const addNamedProject = useCallback( - async (name: string) => { - if (!host || !props.runtimeHostTargetVerified) return; - const result = await window.maka.projects.add(host, { name }); - if (result.ok) await reload(); - }, - [host, props.runtimeHostTargetVerified, reload], - ); + async function addNamedProject(name: string) { + if (!host || !props.runtimeHostTargetVerified) return; + await runRowAction('add', async () => { + setAdding(true); + try { + const result = await window.maka.projects.add(host, { name }); + if (!mountedRef.current) return; + if (!result.ok && result.reason === 'archived') { + const ok = await toast.confirm({ + title: copy.archivedProjectTitle, + description: copy.archivedProjectDescription, + confirmLabel: copy.archivedProjectRestore, + cancelLabel: copy.archivedProjectCancel, + }); + if (!ok || !mountedRef.current) return; + await window.maka.projects.restore(result.projectId, host); + } + } finally { + if (mountedRef.current) setAdding(false); + } + }, copy.actionFailed); + } useEffect(() => { if (!host || !props.runtimeHostTargetVerified) { @@ -280,7 +295,8 @@ export function ProjectsSettingsPage(props: { ref={directoryPickerTriggerRef} variant="secondary" label={copy.addProject} - clickAction={capabilities.chooseHostDirectory + isLoading={adding} + onClick={capabilities.chooseHostDirectory ? () => { if (props.runtimeHostTargetVerified) setProjectDialog('directory'); } @@ -513,7 +529,7 @@ export function ProjectsSettingsPage(props: { onClose={() => setProjectDialog(null)} onRegistered={() => { setProjectDialog(null); - void reload(); + void runRowAction('add', async () => {}, copy.actionFailed); }} /> {projectDialog === 'new' && props.runtimeHostTargetVerified ? ( diff --git a/packages/runtime-host/src/server/project-catalog-coordinator.ts b/packages/runtime-host/src/server/project-catalog-coordinator.ts index 032a2b3f9fe..2c9b3121da9 100644 --- a/packages/runtime-host/src/server/project-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/project-catalog-coordinator.ts @@ -20,6 +20,7 @@ import { JsonArrayPageBudget } from './json-array-page-budget.js'; import { createHash } from 'node:crypto'; +import { reportUnexpectedOperation } from '@maka/core/redaction'; import type { ProjectRecord } from '@maka/core/project'; import { ProjectArchivedError, @@ -145,6 +146,7 @@ export class HostProjectCatalogCoordinator { if (error instanceof TypeError || isInvalidPathError(error)) { return mutationFailure('invalid_request', 'Project catalog input is invalid'); } + reportUnexpectedOperation(`runtime-host:project-catalog:${input.kind}`, error); this.requestDrain(); return mutationFailure( 'commit_outcome_unknown', diff --git a/packages/runtime-host/src/server/skill-catalog-coordinator.ts b/packages/runtime-host/src/server/skill-catalog-coordinator.ts index b827622f2a5..f73fe604abe 100644 --- a/packages/runtime-host/src/server/skill-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/skill-catalog-coordinator.ts @@ -26,6 +26,7 @@ import type { WorkspaceProjection, } from '../protocol/index.js'; import type { ConnectionContext, SkillCatalogOperationHandlerMap } from './operation-dispatcher.js'; +import { reportUnexpectedOperation } from '@maka/core/redaction'; import type { HostCapabilities } from '@maka/runtime/skills'; import { SkillCatalogRepository, @@ -291,6 +292,7 @@ function repositoryFailure( error: { code: 'invalid_request', message: error.message }, } as OperationOutcome; } + reportUnexpectedOperation(`runtime-host:skill-catalog:${operation}`, error); return { ok: false, error: { code: 'internal_failure', message: 'Skill catalog operation failed' }, From 5f1584a34be5ecd68dcc5a0f713c040876da28d6 Mon Sep 17 00:00:00 2001 From: liuzhaochen03 Date: Mon, 21 Sep 2026 15:23:09 +0800 Subject: [PATCH 2/4] fix(desktop): preserve task-entry project restore results Reuse the result-bearing newTasks restore bridge and remove conflicting service and fake definitions. Cover restored-project selection through the Desktop adapter. Generated-by: OpenAI Codex --- .../__tests__/task-entry-controller.test.ts | 27 +++++++++++++++---- .../task-entry-services-adapter.test.ts | 17 +++++++++--- .../src/renderer/features/task-entry/ports.ts | 1 - .../renderer/features/task-entry/testing.ts | 1 - .../desktop/create-task-entry-services.ts | 3 --- 5 files changed, 35 insertions(+), 14 deletions(-) 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 6a9504b2bac..639eaeaba61 100644 --- a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts +++ b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts @@ -23,6 +23,10 @@ import { afterEach, describe, it } from 'node:test'; import { act, createElement } from 'react'; import { LocaleProvider, ToastProvider } from '@maka/ui'; import { getShellCopy } from '../../renderer/locales/shell-copy.js'; +import { + createDesktopTaskEntryServices, + type DesktopTaskEntryBridge, +} from '../../renderer/platform/desktop/create-task-entry-services.js'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { createFakeTaskEntryServices, @@ -204,6 +208,7 @@ describe('useTaskEntryController', () => { }, restoreProject: async (host) => { calls.push({ action: 'restore', ...host }); + return { ok: true, project: project('project-a') }; }, }, }); @@ -339,7 +344,7 @@ describe('useTaskEntryController', () => { assert.equal(controller().selectors.workspacePicker.pending, false); }); - it('prompts to restore an archived Project and selects it after confirmation', async () => { + it('prompts to restore an archived Project and selects it through the Desktop adapter after confirmation', async () => { const { root } = installReactRenderer(); let reads = 0; let restoreCalls = 0; @@ -355,16 +360,26 @@ describe('useTaskEntryController', () => { addProject: async () => ({ ok: false as const, reason: 'archived' as const, - projectId: 'project-a', + projectId: 'project-b', }), restoreProject: async () => { restoreCalls += 1; - return { ok: true as const, project: project('project-a') }; + return { ok: true as const, project: project('project-b') }; }, }, }); - await act(async () => renderController(root, services, [], async () => true)); + const errors: unknown[] = []; + const desktopServices = createDesktopTaskEntryServices({ + newTasks: services.catalog, + projects: { + restore: async () => { + throw new Error('Unexpected legacy restore call'); + }, + }, + } as unknown as DesktopTaskEntryBridge); + await act(async () => renderController(root, desktopServices, errors, async () => true)); + assert.equal(controller().selectors.target?.projectId, 'project-a'); await act(async () => { controller().commands.addProject(); await Promise.resolve(); @@ -372,7 +387,9 @@ describe('useTaskEntryController', () => { await act(async () => {}); assert.equal(restoreCalls, 1); - assert.equal(controller().selectors.target?.projectId, 'project-a'); + assert.deepEqual(errors, []); + assert.equal(controller().selectors.workspacePicker.pending, false); + assert.equal(controller().selectors.target?.projectId, 'project-b'); }); it('reports restore failure and releases the pending state so the user can retry', async () => { 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 656e899dedd..05380d1a02d 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 @@ -30,6 +30,10 @@ describe('createDesktopTaskEntryServices', () => { let disposed = 0; const catalog = { defaultProfileId: 'local', hosts: [] }; const cancelled = { ok: false as const, reason: 'cancelled' as const }; + const restored = { + ok: true as const, + project: { id: 'project-1', name: 'Project', locations: [], available: true }, + }; const bridge = { newTasks: { getCatalog: async () => { @@ -47,6 +51,10 @@ describe('createDesktopTaskEntryServices', () => { calls.push({ name: 'addProject', args }); return cancelled; }, + restoreProject: async (...args: unknown[]) => { + calls.push({ name: 'restoreProject', args }); + return restored; + }, relinkProject: async (...args: unknown[]) => { calls.push({ name: 'relinkProject', args }); return cancelled; @@ -57,8 +65,9 @@ describe('createDesktopTaskEntryServices', () => { calls.push({ name: 'renameProject', args }), archive: async (...args: unknown[]) => calls.push({ name: 'archiveProject', args }), - restore: async (...args: unknown[]) => - calls.push({ name: 'restoreProject', args }), + restore: async () => { + throw new Error('Task Entry must use the result-bearing newTasks restore bridge'); + }, }, sessions: { moveToProject: async (...args: unknown[]) => { @@ -79,7 +88,7 @@ describe('createDesktopTaskEntryServices', () => { await services.catalog.relinkProject(host, 'project-1'); 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.catalog.restoreProject(host, 'project-1'), restored); assert.deepEqual( await services.sessions.relocateWorkspace('session-1', 'project-1'), { ok: true }, @@ -93,7 +102,7 @@ describe('createDesktopTaskEntryServices', () => { { name: 'relinkProject', args: [host, 'project-1'] }, { name: 'renameProject', args: ['project-1', 'Renamed', host] }, { name: 'archiveProject', args: ['project-1', host] }, - { name: 'restoreProject', args: ['project-1', host] }, + { name: 'restoreProject', args: [host, 'project-1'] }, { name: 'moveToProject', args: ['session-1', 'project-1'], diff --git a/apps/desktop/src/renderer/features/task-entry/ports.ts b/apps/desktop/src/renderer/features/task-entry/ports.ts index 66913e4a142..c37d6c3eb73 100644 --- a/apps/desktop/src/renderer/features/task-entry/ports.ts +++ b/apps/desktop/src/renderer/features/task-entry/ports.ts @@ -131,7 +131,6 @@ export interface TaskEntryCatalogService { name: string, ): Promise; archiveProject(host: TaskEntryHostRef, projectId: string): Promise; - restoreProject(host: TaskEntryHostRef, projectId: string): Promise; } export interface TaskEntrySessionService { diff --git a/apps/desktop/src/renderer/features/task-entry/testing.ts b/apps/desktop/src/renderer/features/task-entry/testing.ts index 768acf2e35f..21fdef15d5f 100644 --- a/apps/desktop/src/renderer/features/task-entry/testing.ts +++ b/apps/desktop/src/renderer/features/task-entry/testing.ts @@ -58,7 +58,6 @@ export function createFakeTaskEntryServices( relinkProject: async () => ({ ok: false, reason: 'cancelled' }), renameProject: async () => undefined, archiveProject: async () => undefined, - restoreProject: async () => undefined, }, sessions: { relocateWorkspace: async () => ({ ok: false, reason: 'operation_unavailable' }), 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 be5e60210ef..187fe487ec6 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 @@ -39,9 +39,6 @@ export function createDesktopTaskEntryServices( async archiveProject(host, projectId) { await bridge.projects.archive(projectId, host); }, - async restoreProject(host, projectId) { - await bridge.projects.restore(projectId, host); - }, }, sessions: { async relocateWorkspace(sessionId, projectId) { From 21c8b172ca5535a3c50fe3f31eed2e8ac6b9e15e Mon Sep 17 00:00:00 2001 From: liuzhaochen03 Date: Wed, 7 Oct 2026 00:28:32 +0800 Subject: [PATCH 3/4] fix(desktop): share archived-project restore across registration callers Extract the register/confirm/restore transaction into resolveProjectRegistration and a render-prop ProjectRegistrationBoundary owning single-flight pending state and staleness fences. Settings and the remote directory picker route through it, so the confirm dialog no longer awaits inside Astryx clickAction transitions. Session workspace recovery moves to useSessionWorkspaceRecovery with request-identity and attempt fencing, and toast confirm gains an onConfirm hook that keeps the dialog open while the accepted operation runs. Generated-by: Codex --- apps/desktop/renderer-architecture.json | 2 + .../__tests__/project-add-restore.test.ts | 369 ++++++++++++++++-- .../__tests__/task-entry-controller.test.ts | 271 ++++++++++++- .../task-entry-provider-scope.test.ts | 74 +++- .../task-entry-services-adapter.test.ts | 3 - .../renderer/features/task-entry/README.md | 38 ++ .../resolve-project-registration.ts | 45 +++ .../use-session-workspace-recovery.ts | 242 ++++++++++++ .../controller/use-task-entry-controller.ts | 150 ++----- .../src/renderer/features/task-entry/index.ts | 2 + .../session-workspace-recovery-copy.ts | 84 ++++ .../ui/project-registration-boundary.tsx | 94 +++++ .../task-entry/ui/task-entry-provider.tsx | 20 +- .../remote-project-directory-dialog.tsx | 49 ++- .../settings/projects-settings-page.tsx | 39 +- ...accessibility-runtime-surfaces.stories.tsx | 11 +- .../settings/settings-pages.stories.tsx | 7 +- docs/astryx-surface-file-inventory.md | 3 +- docs/astryx-surface-file-inventory.paths | 1 + packages/ui/src/toast.tsx | 40 +- 20 files changed, 1329 insertions(+), 215 deletions(-) create mode 100644 apps/desktop/src/renderer/features/task-entry/controller/resolve-project-registration.ts create mode 100644 apps/desktop/src/renderer/features/task-entry/controller/use-session-workspace-recovery.ts create mode 100644 apps/desktop/src/renderer/features/task-entry/locales/session-workspace-recovery-copy.ts create mode 100644 apps/desktop/src/renderer/features/task-entry/ui/project-registration-boundary.tsx diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 6c1d8bf7a1d..c85c094fce0 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -1535,6 +1535,7 @@ "actionFactories": [], "dependencyPaths": { "./application/contracts/operation-diagnostics.js": 1, + "./features/task-entry/index.js": 1, "./locales/shell-copy.js": 1, "@astryxdesign/core/Button": 1, "@astryxdesign/core/Dialog": 1, @@ -2263,6 +2264,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "../features/task-entry/index.js": 1, "../locales/settings-projects-copy.js": 1, "../locales/settings-shared-copy.js": 1, "../project-path-display.js": 1, diff --git a/apps/desktop/src/main/__tests__/project-add-restore.test.ts b/apps/desktop/src/main/__tests__/project-add-restore.test.ts index bb2febcec5d..f8f9c844f08 100644 --- a/apps/desktop/src/main/__tests__/project-add-restore.test.ts +++ b/apps/desktop/src/main/__tests__/project-add-restore.test.ts @@ -26,9 +26,10 @@ import { build } from 'esbuild'; import { parseHTML } from 'linkedom'; import { act, createElement, type ComponentType, type ReactNode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui'; +import { AstryxLocaleProvider, LocaleProvider, ToastProvider, useToast } from '@maka/ui'; import type { ProjectRecord } from '@maka/core/project'; import type { DesktopRuntimeHostRef } from '../../preload/bridge-contract.js'; +import type { TaskEntryServices } from '../../renderer/features/task-entry/testing.js'; interface RenderModules { RemoteProjectDirectoryDialog: ComponentType<{ @@ -36,7 +37,10 @@ interface RenderModules { onClose(): void; onRegistered(project: ProjectRecord, host: DesktopRuntimeHostRef): void; }>; - SettingsFixture: ComponentType; + SettingsFixture: ComponentType<{ host?: DesktopRuntimeHostRef; verified?: boolean }>; + SessionRecoveryFixture: ComponentType<{ sessionId?: string }>; + TaskEntryServicesProvider: ComponentType<{ services: TaskEntryServices; children: ReactNode }>; + createFakeTaskEntryServices(): TaskEntryServices; } let components: RenderModules; let bundleDirectory: string; @@ -65,15 +69,29 @@ before(async () => { await build({ stdin: { contents: ` - import { createElement } from 'react'; + import { createElement, Fragment } from 'react'; + import { WorkspacePicker } from '@maka/ui'; + import { TaskEntryRoot, TaskEntryWorkspacePickerConsumer } from './features/task-entry'; import { ProjectsSettingsPage } from './settings/projects-settings-page'; import { RuntimeHostSettingsTarget } from './settings/runtime-host-settings-target'; export { RemoteProjectDirectoryDialog } from './remote-project-directory-dialog'; - export function SettingsFixture() { + export { TaskEntryServicesProvider } from './features/task-entry'; + export { createFakeTaskEntryServices } from './features/task-entry/testing'; + export function SessionRecoveryFixture({ sessionId }) { + return createElement(TaskEntryRoot, { children: ({ commands }) => createElement(Fragment, null, + createElement('button', { onClick: () => commands.openSessionWorkspaceRecovery(sessionId) }, 'Repair workspace'), + createElement(TaskEntryWorkspacePickerConsumer, { + manageProjects() {}, + activeSession: sessionId ? { id: sessionId, profileId: 'local', runtimeHostId: 'host-local', projectId: null, profileKind: 'local' } : undefined, + children: (workspacePicker) => createElement(WorkspacePicker, { workspacePicker }), + }), + ) }); + } + export function SettingsFixture({ host = { profileId: 'remote', hostId: 'host-remote' }, verified = true }) { return createElement(RuntimeHostSettingsTarget, { - host: { profileId: 'remote', hostId: 'host-remote' }, + host, children: createElement(ProjectsSettingsPage, { - settings: { projects: {} }, runtimeHostStatus: 'ready', runtimeHostTargetVerified: true, + settings: { projects: {} }, runtimeHostStatus: 'ready', runtimeHostTargetVerified: verified, onUpdate: async () => {}, onRetryRuntimeHost: async () => {}, onRemoteHostAdded() {}, }), }); @@ -104,7 +122,6 @@ function bridge(overrides: Record = {}) { projects: { getDirectoryRoots: async () => [{ id: 'root', name: 'Root' }], listDirectory: async () => [], registerDirectory: async () => archived, - restore: async () => restored, getSnapshot: async () => ({ projects: [], capabilities: { chooseClientDirectory: true } }), subscribeChanges: () => () => {}, ...overrides, @@ -120,7 +137,7 @@ async function flushFrames() { } } async function click(label: string) { - const button = [...document.querySelectorAll('button')].filter( + const button = [...document.querySelectorAll('button, [role="menuitem"]')].filter( (candidate) => candidate.textContent === label || candidate.getAttribute('aria-label') === label, ).at(-1); assert.ok(button, `missing button ${label}: ${document.body.textContent}`); @@ -128,8 +145,12 @@ async function click(label: string) { await flushFrames(); } -async function submitNamedProject() { +async function submitNamedProject(repetitions = 1) { await click('New project'); + await submitProjectName(repetitions); +} + +async function submitProjectName(repetitions = 1) { const input = document.querySelector('input'); assert.ok(input); await act(async () => { @@ -143,23 +164,69 @@ async function submitNamedProject() { }); const form = document.querySelector('#maka-new-project-form'); assert.ok(form); - await act(async () => form.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true }))); + await act(async () => { + for (let index = 0; index < repetitions; index++) { + form.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true })); + } + }); await flushFrames(); } -for (const outcome of ['restore', 'cancel', 'failure', 'normal', 'stale'] as const) { +// Request routing belongs here; native modal input blocking is checked in Chromium, +// not by rerendering the fixture or dispatching clicks behind an open dialog. +for (const action of ['submit', 'cancel'] as const) { + test(`Session naming dialog ${action} routes only the accepted recovery`, async () => { + const harness = installRenderer(); + const calls: string[] = []; + harness.services.catalog.getCatalog = async () => ({ + defaultProfileId: 'local', + hosts: [{ + profile: { id: 'local', name: 'Local', kind: 'local' }, + hostId: 'host-local', readiness: 'ready', state: 'available', + projects: [], selectedProjectId: null, + capabilities: { chooseClientDirectory: true, chooseHostDirectory: false, selectNoProject: true }, + chatDefaults: { permissionMode: 'ask', thinkingLevel: 'high' }, + }], + }); + harness.services.catalog.addProject = async () => { + calls.push('add'); return { ok: false, reason: 'archived', projectId: 'p' }; + }; + harness.services.catalog.restoreProject = async () => { + calls.push('restore'); return { ok: true, project: restored }; + }; + harness.services.sessions.relocateWorkspace = async (sessionId, projectId) => { + calls.push(`relocate:${sessionId}:${projectId}`); return { ok: true }; + }; + await harness.render(createElement(components.SessionRecoveryFixture, { sessionId: 'session-1' })); + await click('Repair workspace'); + await click('New project'); + assert.ok(document.querySelector('#maka-new-project-form')); + if (action === 'cancel') { + await click('Close'); + assert.equal(document.querySelector('#maka-new-project-form'), null); + assert.deepEqual(calls, []); + } else { + await submitProjectName(); + assert.deepEqual(calls, ['add']); + await click('Restore and use for this session'); + assert.deepEqual(calls, ['add', 'restore', 'relocate:session-1:p']); + } + }); +} + +for (const outcome of ['restore', 'cancel', 'failure', 'normal'] as const) { test(`remote directory registration: ${outcome}`, async () => { const harness = installRenderer(); const accepted: ProjectRecord[] = []; const restores: unknown[][] = []; bridge({ registerDirectory: async () => outcome === 'normal' ? restored : archived, - restore: async (...args: unknown[]) => { - restores.push(args); - if (outcome === 'failure') throw new Error('restore failed'); - return restored; - }, }); + harness.services.catalog.restoreProject = async (...args) => { + restores.push(args); + if (outcome === 'failure') throw new Error('restore failed'); + return { ok: true, project: restored }; + }; const render = (target: DesktopRuntimeHostRef | undefined = host) => harness.render( createElement(components.RemoteProjectDirectoryDialog, { host: target, onClose() {}, onRegistered(project) { accepted.push(project); }, @@ -174,7 +241,6 @@ for (const outcome of ['restore', 'cancel', 'failure', 'normal', 'stale'] as con } assert.equal(accepted.length, 0, 'must not accept an archived project before confirmation'); assert.match(document.body.textContent, /Project archived/); - if (outcome === 'stale') await render({ profileId: 'another', hostId: 'another-host' }); if (outcome === 'cancel') { const dialog = [...document.querySelectorAll('dialog')].find(el => el.textContent.includes('Project archived')); assert.ok(dialog); @@ -184,7 +250,7 @@ for (const outcome of ['restore', 'cancel', 'failure', 'normal', 'stale'] as con await flushFrames(); } else await click('Restore'); if (outcome === 'restore') { - assert.deepEqual(restores, [['p', host]]); + assert.deepEqual(restores, [[host, 'p']]); assert.deepEqual(accepted, [restored]); } else { assert.equal(accepted.length, 0); @@ -205,8 +271,8 @@ for (const failure of ['add', 'restore'] as const) { if (failure === 'add') throw new Error('add failed'); return { ok: false, reason: 'archived', projectId: 'p' }; }, - restore: async () => { throw new Error('restore failed'); }, }); + harness.services.catalog.restoreProject = async () => { throw new Error('restore failed'); }; await harness.render(createElement(components.SettingsFixture)); await submitNamedProject(); if (failure === 'restore') await click('Restore'); @@ -217,27 +283,274 @@ for (const failure of ['add', 'restore'] as const) { assert.equal(add.hasAttribute('disabled'), false); }); } -for (const outcome of ['restore', 'cancel', 'refresh-failure'] as const) { - test(`Settings archived Add: ${outcome}`, async () => { +for (const outcome of ['restore', 'normal', 'refresh-failure'] as const) { + test(`Settings successful Add: ${outcome}`, async () => { const harness = installRenderer(); let restores = 0; let snapshots = 0; bridge({ - add: async () => ({ ok: false, reason: 'archived', projectId: 'p' }), - restore: async () => { restores++; return restored; }, + add: async () => outcome === 'normal' + ? { ok: true, project: restored } + : { ok: false, reason: 'archived', projectId: 'p' }, getSnapshot: async () => { snapshots++; if (snapshots > 1 && outcome === 'refresh-failure') throw new Error('refresh failed'); return { projects: [], capabilities: { chooseClientDirectory: true } }; }, }); + harness.services.catalog.restoreProject = async () => { restores++; return { ok: true, project: restored }; }; await harness.render(createElement(components.SettingsFixture)); await submitNamedProject(); assert.equal(restores, 0); - await click(outcome === 'cancel' ? 'Cancel' : 'Restore'); - assert.equal(restores, outcome === 'cancel' ? 0 : 1); - assert.ok(snapshots > 1); + if (outcome !== 'normal') await click('Restore'); + assert.equal(restores, outcome === 'normal' ? 0 : 1); + assert.equal(snapshots, 2); if (outcome === 'refresh-failure') assert.match(document.body.textContent, /Action failed/); + else assert.doesNotMatch(document.body.textContent, /Action failed/); + }); +} + +for (const stage of ['picker', 'confirmation', 'restore'] as const) { + test(`Settings ${stage} cancellation skips refresh and errors${stage === 'confirmation' ? ' and allows retry' : ''}`, async () => { + const harness = installRenderer(); + let adds = 0; + let restores = 0; + let snapshots = 0; + bridge({ + add: async () => { + adds++; + return stage === 'picker' + ? { ok: false, reason: 'cancelled' } + : { ok: false, reason: 'archived', projectId: 'p' }; + }, + getSnapshot: async () => { + snapshots++; + if (snapshots > 1) throw new Error('unexpected refresh after cancellation'); + return { projects: [], capabilities: { chooseClientDirectory: true } }; + }, + }); + harness.services.catalog.restoreProject = async () => { + restores++; + return { ok: false, reason: 'cancelled' }; + }; + await harness.render(createElement(components.SettingsFixture)); + assert.equal(snapshots, 1); + const attempts = stage === 'confirmation' ? 2 : 1; + for (let attempt = 1; attempt <= attempts; attempt++) { + await submitNamedProject(); + if (stage !== 'picker') { + assert.match(document.body.textContent, /Project archived/); + assert.equal(snapshots, 1, 'waiting for confirmation must not refresh'); + await click(stage === 'confirmation' ? 'Cancel' : 'Restore'); + } + assert.deepEqual({ + adds, + restores, + snapshots, + actionFailed: document.body.textContent.includes('Action failed'), + }, { + adds: attempt, + restores: stage === 'restore' ? attempt : 0, + snapshots: 1, + actionFailed: false, + }); + const add = [...document.querySelectorAll('button')].find(el => el.textContent === 'New project'); + assert.ok(add); + assert.notEqual(add.getAttribute('aria-busy'), 'true'); + assert.equal(add.hasAttribute('disabled'), false); + } + }); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +test('async confirmation preserves the queue and ordinary confirmations after rejection', async () => { + const harness = installRenderer(); + const completion = deferred(); + const outcomes: unknown[] = []; + const cause = new Error('unconfirmed write'); + function Fixture() { + const toast = useToast(); + return createElement('button', { onClick: () => { + void toast.confirm({ title: 'Async first', onConfirm: async () => { + await completion.promise; + throw cause; + } }).then(value => outcomes.push(value), error => outcomes.push(error)); + void toast.confirm({ title: 'Ordinary second' }).then(value => outcomes.push(value)); + } }, 'Queue confirmations'); + } + await harness.render(createElement(Fixture)); + await click('Queue confirmations'); + await click('Confirm'); + assert.match(document.body.textContent, /Async first/); + assert.doesNotMatch(document.body.textContent, /Ordinary second/); + await act(async () => completion.resolve()); + await flushFrames(); + assert.deepEqual(outcomes, [cause]); + assert.match(document.body.textContent, /Ordinary second/); + await click('Confirm'); + assert.deepEqual(outcomes, [cause, true]); + assert.equal(document.querySelector('.maka-confirm-modal'), null); +}); + +test('restore confirmation stays busy and cannot dismiss until completion', async () => { + const harness = installRenderer(); + const completion = deferred(); + let restores = 0; + let accepted = 0; + bridge(); + harness.services.catalog.restoreProject = async () => { + restores++; + await completion.promise; + return { ok: true, project: restored }; + }; + await harness.render(createElement(components.RemoteProjectDirectoryDialog, { + host, onClose() {}, onRegistered() { accepted++; }, + })); + await click('Add this folder'); + const dialog = document.querySelector('.maka-confirm-modal'); + assert.ok(dialog); + const action = [...dialog.querySelectorAll('button')].find(el => el.textContent === 'Restore'); + const cancel = [...dialog.querySelectorAll('button')].find(el => el.textContent === 'Cancel'); + assert.ok(action); + assert.ok(cancel); + // Same-tick double click must start only one write, before React rerenders. + await act(async () => { + action.dispatchEvent(new window.Event('click', { bubbles: true })); + action.dispatchEvent(new window.Event('click', { bubbles: true })); + }); + await flushFrames(); + assert.equal(restores, 1); + assert.equal(dialog.hasAttribute('open'), true); + assert.equal(dialog.getAttribute('aria-busy'), 'true'); + assert.ok(dialog.querySelector('[role="status"]'), 'restore button shows its loading indicator'); + await act(async () => { + cancel.dispatchEvent(new window.Event('click', { bubbles: true })); + dialog.dispatchEvent(new window.Event('cancel', { bubbles: false, cancelable: true })); + }); + await flushFrames(); + assert.equal(dialog.hasAttribute('open'), true); + assert.equal(accepted, 0); + await act(async () => completion.resolve()); + await flushFrames(); + assert.equal(document.querySelector('.maka-confirm-modal'), null); + assert.equal(accepted, 1); +}); + +for (const stage of ['registration', 'confirmation', 'restoration'] as const) { + for (const invalidation of ['host', 'unmount'] as const) { + test(`remote ${stage} ignores ${invalidation} invalidation`, async () => { + const harness = installRenderer(); + const registration = deferred(); + const restoration = deferred<{ ok: true; project: ProjectRecord }>(); + let restores = 0; + let accepted = 0; + bridge({ registerDirectory: () => registration.promise }); + harness.services.catalog.restoreProject = async () => { restores++; return restoration.promise; }; + const render = (target = host) => harness.render(createElement(components.RemoteProjectDirectoryDialog, { + host: target, onClose() {}, onRegistered() { accepted++; }, + })); + await render(); + await click('Add this folder'); + if (stage !== 'registration') { + await act(async () => registration.resolve(archived)); + await flushFrames(); + } + if (stage === 'restoration') { + await click('Restore'); + assert.equal(restores, 1, 'restore starts before invalidation'); + } + if (invalidation === 'host') await render({ profileId: 'other', hostId: 'other-host' }); + else await harness.render(null); + if (stage === 'registration') await act(async () => registration.resolve(archived)); + if (stage === 'confirmation') await click('Restore'); + if (stage === 'restoration') await act(async () => restoration.resolve({ ok: true, project: restored })); + assert.equal(restores, stage === 'restoration' ? 1 : 0); + assert.equal(accepted, 0); + if (stage === 'registration') assert.doesNotMatch(document.body.textContent, /Project archived/); + }); + } +} + +for (const invalidation of ['host', 'verification', 'unmount'] as const) { + test(`Settings confirmation ignores ${invalidation} invalidation without refreshing old Host`, async () => { + const harness = installRenderer(); + let restores = 0; + let snapshots = 0; + bridge({ + add: async (target: DesktopRuntimeHostRef, options: { name: string }) => { + assert.deepEqual(target, host); + assert.deepEqual(options, { name: 'Project' }); + return { ok: false, reason: 'archived', projectId: 'p' }; + }, + getSnapshot: async () => { snapshots++; return { projects: [], capabilities: { chooseClientDirectory: true } }; }, + }); + harness.services.catalog.restoreProject = async () => { restores++; return { ok: true, project: restored }; }; + await harness.render(createElement(components.SettingsFixture)); + await submitNamedProject(); + await harness.render(invalidation === 'unmount' ? null : createElement(components.SettingsFixture, { + ...(invalidation === 'host' ? { host: { profileId: 'other', hostId: 'other-host' } } : { verified: false }), + })); + const reads = snapshots; + await click('Restore'); + assert.equal(restores, 0); + assert.equal(snapshots, reads); + }); +} + +test('remote registration rejects same-tick duplicate submissions without invalidating the first request', async () => { + const harness = installRenderer(); + const registration = deferred(); + let registrations = 0; + const accepted: ProjectRecord[] = []; + bridge({ registerDirectory: async () => { registrations++; return registration.promise; } }); + await harness.render(createElement(components.RemoteProjectDirectoryDialog, { + host, onClose() {}, onRegistered(project) { accepted.push(project); }, + })); + const button = [...document.querySelectorAll('button')].find(el => el.textContent === 'Add this folder'); + assert.ok(button); + await act(async () => { + button.dispatchEvent(new window.Event('click', { bubbles: true })); + button.dispatchEvent(new window.Event('click', { bubbles: true })); + }); + assert.equal(registrations, 1); + await act(async () => registration.resolve(restored)); + assert.deepEqual(accepted, [restored]); +}); + +test('Settings Add stays single-flight while the folder picker is pending', async () => { + const harness = installRenderer(); + const addition = deferred<{ ok: true; project: ProjectRecord }>(); + let adds = 0; + bridge({ add: async () => { adds++; return addition.promise; } }); + await harness.render(createElement(components.SettingsFixture)); + await submitNamedProject(2); + assert.equal(adds, 1); + const add = document.querySelector('button[aria-busy="true"]'); + assert.ok(add); + await act(async () => addition.resolve({ ok: true, project: restored })); + assert.notEqual(add.getAttribute('aria-busy'), 'true'); +}); + +for (const outcome of ['cancelled', 'archived'] as const) { + test(`remote handles restore result ${outcome} without accepting an archived project`, async () => { + const harness = installRenderer(); + let accepted = 0; + bridge(); + harness.services.catalog.restoreProject = async () => outcome === 'cancelled' + ? { ok: false, reason: 'cancelled' } + : { ok: false, reason: 'archived', projectId: 'p' }; + await harness.render(createElement(components.RemoteProjectDirectoryDialog, { + host, onClose() {}, onRegistered() { accepted++; }, + })); + await click('Add this folder'); + await click('Restore'); + assert.equal(accepted, 0); + assert.equal(Boolean(document.querySelector('.remoteProjectDirectoryError')), outcome === 'archived'); }); } @@ -265,18 +578,20 @@ function installRenderer() { requestAnimationFrame: (callback: FrameRequestCallback) => { frames.push(callback); return frames.length; }, cancelAnimationFrame: () => {}, IS_REACT_ACT_ENVIRONMENT: true, }); + const services = components.createFakeTaskEntryServices(); const container = document.getElementById('root'); assert.ok(container); const root = createRoot(container); mountedRoot = root; return { document, + services, async render(children: ReactNode) { await act(async () => root.render(createElement(LocaleProvider, { locale: 'en', children: createElement(AstryxLocaleProvider, { children: createElement(ToastProvider, { - children, + children: createElement(components.TaskEntryServicesProvider, { services, children }), }), }), }))); 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 639eaeaba61..d8c08754586 100644 --- a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts +++ b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts @@ -105,7 +105,7 @@ let latestController: TaskEntryController | undefined; function ControllerProbe(props: { reportError(error: unknown): void; - confirm?(input: { title: string }): Promise; + confirm?(input: { title: string; onConfirm?(): Promise }): Promise; }) { latestController = useTaskEntryController({ reportError: props.reportError, @@ -125,10 +125,11 @@ function renderController( services: TaskEntryServices, errors: unknown[] = [], confirm?: (input: { title: string }) => Promise, + locale: 'en' | 'zh-CN' | 'zh-TW' = 'en', ) { root.render( createElement(LocaleProvider, { - locale: 'en', + locale, children: createElement( ToastProvider, null, @@ -137,7 +138,11 @@ function renderController( { services }, createElement(ControllerProbe, { reportError: (error: unknown) => errors.push(error), - confirm, + confirm: confirm ? async (input) => { + const accepted = await confirm(input); + if (accepted) await input.onConfirm?.(); + return accepted; + } : undefined, }), ), ), @@ -145,11 +150,265 @@ function renderController( ); } +async function sessionRecoveryHarness(options: { + add?: TaskEntryServices['catalog']['addProject']; + restore?: TaskEntryServices['catalog']['restoreProject']; + relocate?: TaskEntryServices['sessions']['relocateWorkspace']; + getCatalog?: TaskEntryServices['catalog']['getCatalog']; + confirm?: (input: { title: string; description?: string; confirmLabel?: string }) => Promise; +} = {}) { + const { root } = installReactRenderer(); + const calls: string[] = []; + const errors: unknown[] = []; + let restoredProject: ReturnType | undefined; + const services = createFakeTaskEntryServices({ + catalog: { + ...createFakeTaskEntryServices().catalog, + getCatalog: async () => { + calls.push('catalog'); + return options.getCatalog ? options.getCatalog() : catalog(readyHost({ + projects: [project('project-a'), ...(restoredProject ? [restoredProject] : [])], + })); + }, + addProject: async (host, name) => { + calls.push(`add:${host.profileId}:${host.hostId}:${name}`); + return options.add ? options.add(host, name) : { ok: false, reason: 'archived', projectId: 'project-b' }; + }, + restoreProject: async (host, id) => { + calls.push(`restore:${host.profileId}:${host.hostId}:${id}`); + const result = options.restore ? await options.restore(host, id) : { ok: true as const, project: project(id) }; + if (result.ok) restoredProject = { ...project(result.project.id), ...result.project }; + return result; + }, + archiveProject: async () => { assert.fail('Recovery must never roll back by archiving'); }, + renameProject: async () => { assert.fail('Restoring must preserve the original name'); }, + }, + sessions: { + relocateWorkspace: async (sessionId, projectId) => { + calls.push(`relocate:${sessionId}:${projectId}`); + return options.relocate ? options.relocate(sessionId, projectId) : { ok: true }; + }, + }, + }); + const confirm = async (input: { title: string; description?: string; confirmLabel?: string }) => { + calls.push('confirm'); + return options.confirm ? options.confirm(input) : true; + }; + await act(async () => renderController(root, services, errors, confirm)); + await act(async () => controller().commands.openSessionWorkspaceRecovery('session-1')); + const request = controller().selectors.sessionWorkspaceRecovery!; + const add = () => controller().commands.addSessionWorkspace({ + sessionId: 'session-1', + profileId: 'local', + host: { profileId: 'local', hostId: 'host-local' }, + name: 'Do not rename restored project', + request, + }); + return { root, calls, errors, services, request, add, confirm }; +} + afterEach(() => { latestController = undefined; cleanupFakeDom(); }); +describe('Session workspace archived-project recovery', () => { + it('confirms use for this Session, restores original identity, then relocates without selecting a new-task Project', async () => { + const harness = await sessionRecoveryHarness({ + confirm: async (input) => { + assert.equal(input.confirmLabel, 'Restore and use for this session'); + assert.match(input.description ?? '', /this session’s workspace/); + return true; + }, + }); + const initialTarget = controller().selectors.target; + await act(async () => assert.equal(await harness.add(), true)); + assert.deepEqual(harness.calls, [ + 'catalog', 'add:local:host-local:Do not rename restored project', 'confirm', + 'restore:local:host-local:project-b', 'relocate:session-1:project-b', 'catalog', + ]); + assert.deepEqual(controller().selectors.target, initialTarget); + assert.ok(controller().selectors.workspacePicker.groups[0]?.projects.some((project) => project.id === 'project-b')); + assert.equal(controller().selectors.sessionWorkspaceRecovery, undefined); + assert.equal(controller().selectors.workspacePicker.pending, false); + assert.deepEqual(harness.errors, []); + }); + + for (const stage of ['directory', 'confirmation', 'restoration'] as const) { + it(`cancels at ${stage} without relocation, refresh, or an error`, async () => { + const harness = await sessionRecoveryHarness({ + ...(stage === 'directory' ? { add: async () => ({ ok: false as const, reason: 'cancelled' as const }) } : {}), + ...(stage === 'confirmation' ? { confirm: async () => false } : {}), + ...(stage === 'restoration' ? { restore: async () => ({ ok: false as const, reason: 'cancelled' as const }) } : {}), + }); + await act(async () => assert.equal(await harness.add(), false)); + assert.equal(harness.calls.some((call) => call.startsWith('relocate')), false); + assert.equal(harness.calls.filter((call) => call === 'catalog').length, 1); + assert.equal(harness.calls.filter((call) => call.startsWith('restore')).length, stage === 'restoration' ? 1 : 0); + assert.equal(controller().selectors.sessionWorkspaceRecovery, harness.request); + assert.equal(controller().selectors.workspacePicker.pending, false); + assert.deepEqual(harness.errors, []); + }); + } + + it('retains the restored Project and recovery intent when relocation fails', async () => { + const harness = await sessionRecoveryHarness({ relocate: async () => ({ ok: false, reason: 'session_busy' }) }); + await act(async () => assert.equal(await harness.add(), false)); + assert.equal(controller().selectors.sessionWorkspaceRecovery, harness.request); + assert.equal(controller().selectors.workspacePicker.pending, false); + assert.equal(harness.errors.length, 1); + assert.match(JSON.stringify(harness.errors), /project was restored and has not been archived again/); + assert.equal(harness.calls.at(-1), 'catalog'); + // Retry uses the normal existing-Project path; it does not restore again. + await act(async () => controller().commands.relocateSessionWorkspace({ + sessionId: 'session-1', profileId: 'local', projectId: 'project-b', request: harness.request, + })); + assert.equal(harness.calls.filter((call) => call.startsWith('restore')).length, 1); + assert.equal(harness.calls.filter((call) => call.startsWith('relocate')).length, 2); + }); + + it('rejects an unavailable restored directory without losing the partial success', async () => { + const harness = await sessionRecoveryHarness({ + restore: async () => ({ ok: true, project: { ...project('project-b'), available: false } }), + }); + await act(async () => assert.equal(await harness.add(), false)); + assert.equal(harness.calls.some((call) => call.startsWith('relocate')), false); + assert.match(JSON.stringify(harness.errors), /project was restored.*directory is unavailable/); + assert.equal(controller().selectors.sessionWorkspaceRecovery, harness.request); + }); + + it('does not claim an IPC restore rejection is a definite failure or retry it automatically', async () => { + const fail = async (): Promise => { throw new Error('commit_outcome_unknown'); }; + const harness = await sessionRecoveryHarness({ restore: fail }); + await act(async () => assert.equal(await harness.add(), false)); + assert.match(JSON.stringify(harness.errors), /Could not confirm/); + assert.match(JSON.stringify(harness.errors), /before deciding to retry/); + assert.equal(harness.calls.filter((call) => call.startsWith('restore')).length, 1); + assert.equal(harness.calls.some((call) => call.startsWith('relocate')), false); + assert.equal(controller().selectors.sessionWorkspaceRecovery, harness.request); + assert.equal(controller().selectors.workspacePicker.pending, false); + }); + + it('reports a restore that still returns archived instead of silently stopping', async () => { + const harness = await sessionRecoveryHarness({ restore: async () => ({ ok: false, reason: 'archived', projectId: 'project-b' }) }); + await act(async () => assert.equal(await harness.add(), false)); + assert.equal(harness.calls.some((call) => call.startsWith('relocate')), false); + assert.equal(harness.errors.length, 1); + assert.equal(controller().selectors.workspacePicker.pending, false); + }); + + it('keeps relocation successful when the subsequent catalog refresh fails', async () => { + let reads = 0; + const harness = await sessionRecoveryHarness({ + getCatalog: async () => { + if (++reads > 1) throw new Error('offline'); + return catalog(); + }, + }); + await act(async () => assert.equal(await harness.add(), true)); + assert.equal(controller().selectors.sessionWorkspaceRecovery, undefined); + assert.equal(controller().selectors.workspacePicker.pending, false); + assert.equal(harness.errors.length, 1); + assert.match(JSON.stringify(harness.errors), /Could not refresh projects/); + assert.doesNotMatch(JSON.stringify(harness.errors), /Could not move|Could not confirm/); + }); + + it('keeps one physical mutation in flight through confirmation, restore, and relocation', async () => { + const confirmed = deferred(); + const restored = deferred>>(); + const relocated = deferred>>(); + const harness = await sessionRecoveryHarness({ + confirm: () => confirmed.promise, + restore: () => restored.promise, + relocate: () => relocated.promise, + }); + let running: Promise | undefined; + await act(async () => { + running = harness.add(); + assert.equal(await harness.add(), false); + }); + for (const advance of [ + () => confirmed.resolve(true), + () => restored.resolve({ ok: true, project: project('project-b') }), + ]) { + assert.equal(controller().selectors.workspacePicker.pending, true); + await act(async () => { + assert.equal(await harness.add(), false); + assert.equal(await controller().commands.relocateSessionWorkspace({ + sessionId: 'session-1', profileId: 'local', projectId: 'project-a', + }), false); + advance(); + }); + } + await act(async () => { + assert.equal(await harness.add(), false); + relocated.resolve({ ok: true }); + assert.equal(await running, true); + }); + assert.equal(harness.calls.filter((call) => call.startsWith('add')).length, 1); + assert.equal(harness.calls.filter((call) => call.startsWith('restore')).length, 1); + assert.equal(harness.calls.filter((call) => call.startsWith('relocate')).length, 1); + }); + + for (const stage of ['confirm', 'relocate'] as const) { + it(`does not continue an obsolete ${stage} or close a newer request for the same Session`, async () => { + const gate = deferred(); + const harness = await sessionRecoveryHarness({ + add: async () => { + return { ok: false, reason: 'archived', projectId: 'project-b' }; + }, + confirm: async () => { if (stage === 'confirm') await gate.promise; return true; }, + restore: async () => { + return { ok: true, project: project('project-b') }; + }, + relocate: async () => { if (stage === 'relocate') await gate.promise; return { ok: true }; }, + }); + let running: Promise | undefined; + await act(async () => { running = harness.add(); }); + await act(async () => controller().commands.openSessionWorkspaceRecovery('session-1')); + const newerRequest = controller().selectors.sessionWorkspaceRecovery; + assert.notEqual(newerRequest, harness.request); + await act(async () => { + assert.equal(await harness.add(), false); + gate.resolve(); + assert.equal(await running, false); + }); + assert.equal(controller().selectors.sessionWorkspaceRecovery, newerRequest); + assert.equal(harness.calls.filter((call) => call.startsWith('relocate')).length, stage === 'relocate' ? 1 : 0); + if (stage === 'confirm') assert.equal(harness.calls.some((call) => call.startsWith('restore')), false); + assert.deepEqual(harness.errors, []); + }); + } + + for (const invalidation of ['close', 'unmount', 'service', 'host'] as const) { + it(`does not restore after ${invalidation} invalidates the confirmation`, async () => { + const confirmation = deferred(); + let nextCatalog = catalog(); + const harness = await sessionRecoveryHarness({ confirm: () => confirmation.promise, getCatalog: async () => nextCatalog }); + let running: Promise | undefined; + await act(async () => { running = harness.add(); }); + await act(async () => { + if (invalidation === 'close') controller().commands.closeSessionWorkspaceRecovery(); + if (invalidation === 'unmount') harness.root.unmount(); + if (invalidation === 'service') renderController(harness.root, { + ...harness.services, catalog: { ...harness.services.catalog }, + }, harness.errors, harness.confirm); + if (invalidation === 'host') { + nextCatalog = catalog(readyHost({ hostId: 'replacement' })); + await controller().commands.refresh(); + } + }); + if (invalidation === 'host') { + nextCatalog = catalog(); + await act(async () => controller().commands.refresh()); + } + await act(async () => { confirmation.resolve(true); assert.equal(await running, false); }); + assert.equal(harness.calls.some((call) => call.startsWith('restore') || call.startsWith('relocate')), false); + assert.deepEqual(harness.errors, []); + }); + } +}); + describe('useTaskEntryController', () => { it('projects the target and keeps draft identity in sync with Workspace Picker selections', async () => { const { root } = installReactRenderer(); @@ -372,11 +631,7 @@ describe('useTaskEntryController', () => { const errors: unknown[] = []; const desktopServices = createDesktopTaskEntryServices({ newTasks: services.catalog, - projects: { - restore: async () => { - throw new Error('Unexpected legacy restore call'); - }, - }, + projects: {}, } as unknown as DesktopTaskEntryBridge); await act(async () => renderController(root, desktopServices, errors, async () => true)); assert.equal(controller().selectors.target?.projectId, 'project-a'); 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 fc02cbeb060..71836cc40de 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 @@ -18,6 +18,7 @@ */ import { strict as assert } from 'node:assert'; +import { deferred } from '@maka/core/test-only/async-primitives'; import { afterEach, describe, it } from 'node:test'; import { act, createElement, Fragment, type ReactNode } from 'react'; import { @@ -123,14 +124,18 @@ function HostProbe() { return null; } -function RecoveryWorkspaceProbe({ local = false }: { local?: boolean }) { +function RecoveryWorkspaceProbe({ local = false, sessionId = 'session-1', hostId = 'host-local' }: { + local?: boolean; + sessionId?: string; + hostId?: string; +}) { return createElement(TaskEntryWorkspacePickerConsumer, { manageProjects() {}, activeSession: local ? { - id: 'session-1', + id: sessionId, profileId: 'local', - runtimeHostId: 'host-local', + runtimeHostId: hostId, projectId: null, profileKind: 'local', } @@ -175,11 +180,11 @@ function RecoveryShellProbe() { }); } -function LocalRecoveryShellProbe() { +function LocalRecoveryShellProbe(props: { sessionId?: string; hostId?: string }) { return createElement(TaskEntryRoot, { children: (taskEntry) => { latestTaskEntry = taskEntry; - return createElement(RecoveryWorkspaceProbe, { local: true }); + return createElement(RecoveryWorkspaceProbe, { local: true, ...props }); }, }); } @@ -221,6 +226,65 @@ afterEach(() => { }); describe('TaskEntryRoot render scope', () => { + for (const transition of ['session', 'host', 'unmount'] as const) { + it(`invalidates a directory handoff on ${transition} change and rejects its retained callback`, async () => { + const { root } = installReactRenderer(); + const picked = deferred>>(); + const calls: string[] = []; + const services = createFakeTaskEntryServices({ + catalog: { + ...createFakeTaskEntryServices().catalog, + getCatalog: async () => localCatalog([project('project-a')]), + addProject: async () => { calls.push('add'); return picked.promise; }, + }, + sessions: { relocateWorkspace: async () => { calls.push('relocate'); return { ok: true }; } }, + }); + await act(async () => renderProvider(root, services, createElement(LocalRecoveryShellProbe))); + await act(async () => latestTaskEntry?.commands.openSessionWorkspaceRecovery('session-1')); + const oldAdd = latestRecoveryPicker?.groups[0]?.onAdd; + assert.ok(oldAdd); + await act(async () => oldAdd('Imported')); + assert.equal(latestRecoveryPicker?.pending, true); + await act(async () => renderProvider(root, services, createElement(LocalRecoveryShellProbe, { + ...(transition === 'session' ? { sessionId: 'session-2' } : {}), + ...(transition === 'host' ? { hostId: 'replacement' } : {}), + }))); + if (transition === 'unmount') await act(async () => root.unmount()); + if (transition !== 'unmount') { + await act(async () => renderProvider(root, services, createElement(LocalRecoveryShellProbe))); + await act(async () => latestTaskEntry?.commands.openSessionWorkspaceRecovery('session-1')); + } + await act(async () => { picked.resolve({ ok: true, project: project('project-b') }); }); + await act(async () => oldAdd('Stale dialog')); + assert.deepEqual(calls, ['add']); + if (transition !== 'unmount') { + assert.equal(latestRecoveryPicker?.showForActiveSession, true); + assert.equal(latestRecoveryPicker?.isMenuOpen, true); + assert.equal(latestRecoveryPicker?.pending, false); + } + }); + } + + it('does not let an already-submitted move close the next Session recovery picker', async () => { + const { root } = installReactRenderer(); + const moved = deferred>>(); + const calls: string[] = []; + const services = createFakeTaskEntryServices({ + catalog: { ...createFakeTaskEntryServices().catalog, getCatalog: async () => localCatalog([project('project-a')]) }, + sessions: { relocateWorkspace: async (sessionId) => { calls.push(sessionId); return moved.promise; } }, + }); + await act(async () => renderProvider(root, services, createElement(LocalRecoveryShellProbe))); + await act(async () => latestTaskEntry?.commands.openSessionWorkspaceRecovery('session-1')); + await act(async () => latestRecoveryPicker?.groups[0]?.onSelectProject?.('project-a')); + await act(async () => renderProvider(root, services, createElement(LocalRecoveryShellProbe, { sessionId: 'session-2' }))); + await act(async () => latestTaskEntry?.commands.openSessionWorkspaceRecovery('session-2')); + await act(async () => { moved.resolve({ ok: true }); }); + assert.deepEqual(calls, ['session-1']); + assert.equal(latestRecoveryPicker?.showForActiveSession, true); + assert.equal(latestRecoveryPicker?.isMenuOpen, true); + assert.equal(latestRecoveryPicker?.pending, false); + }); + it('keeps a controller-only directory handoff below the shell frame', async () => { const { root } = installReactRenderer(); const services = createFakeTaskEntryServices({ 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 05380d1a02d..28fb8f63aee 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 @@ -65,9 +65,6 @@ describe('createDesktopTaskEntryServices', () => { calls.push({ name: 'renameProject', args }), archive: async (...args: unknown[]) => calls.push({ name: 'archiveProject', args }), - restore: async () => { - throw new Error('Task Entry must use the result-bearing newTasks restore bridge'); - }, }, sessions: { moveToProject: async (...args: unknown[]) => { diff --git a/apps/desktop/src/renderer/features/task-entry/README.md b/apps/desktop/src/renderer/features/task-entry/README.md index c11639973d7..3a00b203a14 100644 --- a/apps/desktop/src/renderer/features/task-entry/README.md +++ b/apps/desktop/src/renderer/features/task-entry/README.md @@ -66,6 +66,44 @@ workspace-unavailable notice instead. - Draft identity is target-scoped. An unresolved catalog always uses the stable unresolved new-task key, preserving reload and target-switch handoff behavior. +## Registration recovery boundary + +`ProjectRegistrationBoundary` provides a render-prop transaction for legacy +registration callers. It owns single-flight pending state, archived-project +confirmation, and recovery through the injected catalog. Cancellation is silent; +failures return to the caller's existing error surface. Host changes, lost Host +verification, locale changes, unmount, and caller request fences prevent stale +confirmation or recovery results from continuing. + +Project Settings keeps its original folder-picker add operation, row-action +errors, and refresh. The shared remote-directory browser keeps directory I/O, +navigation, request sequencing, and accepted-project handoff. This boundary does +not take ownership of either surface's broader lifecycle. + +## Existing Session workspace recovery + +`useSessionWorkspaceRecovery` owns the repair request, not the new-task target. +Its Add action shares `resolveProjectRegistration` with other registration +callers: confirm restoring the original Project **and using it for this Session**, +restore it, check availability, then call the existing Session relocation port. +Even a same-Project move must pass Runtime Host eligibility checks. + +- Restoration and relocation are separate commits. A rejected move leaves the + Project restored and the repair picker available, with a partial-success error. + Recovery never archives the Project to roll back, renames it, creates a Session, + or sends a message. +- Cancellation does not restore, relocate, or refresh. An unavailable restored + directory cannot be used; the user can relocate it or choose another Project. +- Opaque restore/move rejections are unconfirmed outcomes, not proof that the + write failed. The UI asks the user to check state before retrying; it never + resubmits automatically. Catalog refresh failure cannot undo a committed move. +- Each request has an identity even when reopening the same Session. Leaving + that Session/Host, closing recovery, changing service/locale, or unmounting + fences pending work. Already-submitted writes cannot be undone, but their + results cannot close a newer repair request. Single-flight remains held until + the physical attempt settles. Closing the dropdown for the naming dialog is + only a UI handoff and keeps the request alive. + ## Non-ownership Session creation, first-send/task submission, Composer state, attachments, diff --git a/apps/desktop/src/renderer/features/task-entry/controller/resolve-project-registration.ts b/apps/desktop/src/renderer/features/task-entry/controller/resolve-project-registration.ts new file mode 100644 index 00000000000..8e94ec497f5 --- /dev/null +++ b/apps/desktop/src/renderer/features/task-entry/controller/resolve-project-registration.ts @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { TaskEntryProjectMutationResult } from '../ports.js'; + +/** Resolves registration without deciding how the caller uses the Project. */ +export async function resolveProjectRegistration(input: { + register(): Promise; + confirm(onConfirm: () => Promise): Promise; + restore(projectId: string): Promise; + isCurrent(): boolean; +}): Promise { + if (!input.isCurrent()) return; + const result = await input.register(); + if (!input.isCurrent()) return; + if (result.ok || result.reason !== 'archived') return result; + let restored: TaskEntryProjectMutationResult | undefined; + const confirmed = await input.confirm(async () => { + if (!input.isCurrent()) return; + restored = await input.restore(result.projectId); + }); + if (!input.isCurrent()) return; + if (!confirmed) return { ok: false, reason: 'cancelled' }; + if (!restored) return; + if (!restored.ok && restored.reason === 'archived') { + throw new Error('Project remains archived after restore'); + } + return restored; +} diff --git a/apps/desktop/src/renderer/features/task-entry/controller/use-session-workspace-recovery.ts b/apps/desktop/src/renderer/features/task-entry/controller/use-session-workspace-recovery.ts new file mode 100644 index 00000000000..78d19e5208c --- /dev/null +++ b/apps/desktop/src/renderer/features/task-entry/controller/use-session-workspace-recovery.ts @@ -0,0 +1,242 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useCallback, useLayoutEffect, useRef, useState, type RefObject } from 'react'; +import { useToast, useUiLocale, type ConfirmInput } from '@maka/ui'; +import { unexpectedOperationFallback } from '@maka/core/redaction'; +import { getShellCopy } from '../../../locales/shell-copy.js'; +import { getSessionWorkspaceRecoveryCopy } from '../locales/session-workspace-recovery-copy.js'; +import { isReadyTaskEntryHost } from '../model/task-entry-selection.js'; +import type { TaskEntryCatalog, TaskEntryError, TaskEntryHostRef } from '../ports.js'; +import { useTaskEntryServices } from '../services-context.js'; +import { resolveProjectRegistration } from './resolve-project-registration.js'; + +export interface SessionWorkspaceRecoveryRequest { + readonly sessionId: string; +} + +export interface RelocateSessionWorkspaceInput { + sessionId: string; + profileId: string; + projectId: string; + /** Fences callbacks retained by an older picker/dialog. */ + request?: SessionWorkspaceRecoveryRequest; +} + +export interface AddSessionWorkspaceInput { + sessionId: string; + profileId: string; + host: TaskEntryHostRef; + name: string; + request?: SessionWorkspaceRecoveryRequest; +} + +interface Attempt { + readonly host: TaskEntryHostRef; + readonly adding: boolean; + invalid: boolean; +} + +function hostIsCurrent(catalog: TaskEntryCatalog, attempt: Attempt): boolean { + return catalog.hosts.some((host) => + isReadyTaskEntryHost(host) && + host.profile.id === attempt.host.profileId && host.hostId === attempt.host.hostId && + (!attempt.adding || (host.profile.kind === 'local' && host.capabilities.chooseClientDirectory)), + ); +} + +/** Recovery owns a Session intent, never the new-task Project selection. */ +export function useSessionWorkspaceRecovery({ + catalog, + catalogRef, + mutationPendingRef, + setPending, + refresh, + reportError, + confirm, +}: { + catalog: TaskEntryCatalog; + catalogRef: RefObject; + mutationPendingRef: RefObject; + setPending(pending: boolean): void; + refresh(): Promise; + reportError(error: TaskEntryError): void; + confirm?: (input: ConfirmInput) => Promise; +}) { + const { catalog: service, sessions } = useTaskEntryServices(); + const toast = useToast(); + const locale = useUiLocale(); + const copy = getSessionWorkspaceRecoveryCopy(locale); + const projectCopy = getShellCopy(locale).projectActions; + const moveCopy = getShellCopy(locale).sessionRowActions; + const [sessionWorkspaceRecovery, setRecovery] = useState(); + const requestRef = useRef(undefined); + const attemptRef = useRef(undefined); + const mounted = useRef(false); + + useLayoutEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + if (attemptRef.current) attemptRef.current.invalid = true; + }; + }, [service, sessions, locale]); + + useLayoutEffect(() => { + const attempt = attemptRef.current; + // Invalidation is monotonic: reconnecting to the same Host cannot revive it. + if (attempt && !hostIsCurrent(catalog, attempt)) attempt.invalid = true; + }, [catalog]); + + const openSessionWorkspaceRecovery = useCallback((sessionId: string): void => { + if (attemptRef.current) attemptRef.current.invalid = true; + const request = { sessionId }; + requestRef.current = request; + setRecovery(request); + }, []); + + const closeSessionWorkspaceRecovery = useCallback((expected?: SessionWorkspaceRecoveryRequest): void => { + if (expected && expected !== requestRef.current) return; + if (attemptRef.current) attemptRef.current.invalid = true; + requestRef.current = undefined; + setRecovery(undefined); + }, []); + + const run = useCallback(async (input: AddSessionWorkspaceInput | RelocateSessionWorkspaceInput): Promise => { + const request = input.request ?? requestRef.current; + if (!mounted.current || mutationPendingRef.current || + (input.request && input.request !== requestRef.current) || + (request && request.sessionId !== input.sessionId)) return false; + const adding = 'host' in input; + if (adding && (!request || input.host.profileId !== input.profileId)) return false; + const host = catalogRef.current.hosts.find((candidate) => + candidate.profile.id === input.profileId && isReadyTaskEntryHost(candidate), + ); + if (!host || !isReadyTaskEntryHost(host)) return false; + const attempt: Attempt = { + host: adding ? input.host : { profileId: input.profileId, hostId: host.hostId }, + adding, + invalid: false, + }; + if (!hostIsCurrent(catalogRef.current, attempt)) return false; + const current = () => mounted.current && !attempt.invalid && + requestRef.current === request && hostIsCurrent(catalogRef.current, attempt); + attemptRef.current = attempt; + mutationPendingRef.current = true; + setPending(true); + const operation: { phase: 'register' | 'restore' | 'relocate' } = { phase: 'register' }; + let projectChanged = false; + let restored = false; + let moved = false; + try { + let projectId: string; + if (adding) { + const result = await resolveProjectRegistration({ + register: () => service.addProject(input.host, input.name), + confirm: (onConfirm) => (confirm ?? toast.confirm)({ + onConfirm, + title: projectCopy.archivedProjectTitle, + description: copy.confirmDescription, + confirmLabel: copy.confirmLabel, + cancelLabel: projectCopy.archivedProjectCancel, + }), + restore: async (id) => { + operation.phase = 'restore'; + const result = await service.restoreProject(input.host, id); + restored = result.ok; + projectChanged = result.ok; + return result; + }, + isCurrent: current, + }); + if (!result?.ok || !current()) return false; + projectChanged = true; + if (!result.project.available || result.project.archivedAt !== undefined) { + reportError({ + title: moveCopy.moveFailedTitle, + description: `${restored ? `${copy.restoredPrefix} ` : ''}${copy.projectUnavailable}`, + profileId: input.profileId, + }); + return false; + } + projectId = result.project.id; + } else { + projectId = input.projectId; + } + operation.phase = 'relocate'; + // Even an unchanged Project must pass Runtime Host eligibility checks. + const result = await sessions.relocateWorkspace(input.sessionId, projectId); + if (!current()) return false; + if (!result.ok) { + reportError({ + title: moveCopy.moveFailedTitle, + description: `${restored ? `${copy.restoredPrefix} ` : ''}${moveCopy.moveFailures[result.reason]}`, + profileId: input.profileId, + }); + return false; + } + moved = true; + return true; + } catch (cause) { + if (current()) { + // IPC rejection does not prove the write failed. Never automatically retry. + const title = operation.phase === 'relocate' ? copy.moveUnconfirmedTitle + : operation.phase === 'restore' ? copy.restoreUnconfirmedTitle : copy.registrationUnconfirmedTitle; + const description = operation.phase === 'relocate' ? copy.moveUnconfirmed + : operation.phase === 'restore' ? copy.restoreUnconfirmed : copy.registrationUnconfirmed; + reportError({ + title, + description: unexpectedOperationFallback( + cause, + `${restored ? `${copy.restoredPrefix} ` : ''}${description}`, + 'session-workspace-recovery', + ), + profileId: input.profileId, + }); + } + return false; + } finally { + // Catalog refresh is not part of the relocation commit. Its failure must + // neither roll back a restored Project nor turn a successful move into failure. + if (projectChanged && current()) { + try { + await refresh(); + } catch { + if (current()) reportError({ + title: copy.refreshFailedTitle, + description: copy.refreshFailed, + profileId: input.profileId, + }); + } + } + if (moved && current()) closeSessionWorkspaceRecovery(request); + attemptRef.current = undefined; + mutationPendingRef.current = false; + if (mounted.current) setPending(false); + } + }, [catalogRef, closeSessionWorkspaceRecovery, confirm, copy, locale, moveCopy, mutationPendingRef, projectCopy, refresh, reportError, service, sessions, setPending, toast]); + + return { + sessionWorkspaceRecovery, + openSessionWorkspaceRecovery, + closeSessionWorkspaceRecovery, + addSessionWorkspace: run, + relocateSessionWorkspace: run, + }; +} 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 f66b2e089a1..e1b049b6352 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 @@ -31,6 +31,7 @@ import { type RuntimeHostProfileKind, } from '@maka/runtime-host/profile-kind'; import { + type ConfirmInput, getConversationCopy, type WorkspacePickerModel, useToast, @@ -60,17 +61,19 @@ import type { import { useTaskEntryServices } from '../services-context.js'; import type { TaskEntryHostModel } from '../ui/task-entry-host.js'; import { runtimeHostProjectKey } from '../../../application/contracts/runtime-host-project-key.js'; +import { resolveProjectRegistration } from './resolve-project-registration.js'; +import { + useSessionWorkspaceRecovery, + type AddSessionWorkspaceInput, + type RelocateSessionWorkspaceInput, + type SessionWorkspaceRecoveryRequest, +} from './use-session-workspace-recovery.js'; export interface UseTaskEntryControllerInput { reportError(error: TaskEntryError): void; manageProjects(profileId: string): void; /** Defaults to the app toast confirm dialog; injected in tests. */ - confirm?(input: { - title: string; - description?: string; - confirmLabel?: string; - cancelLabel?: string; - }): Promise; + confirm?(input: ConfirmInput): Promise; } export interface TaskEntryControllerSelectors { @@ -88,7 +91,7 @@ export interface TaskEntryControllerSelectors { readonly defaultProfileId: string; readonly usesDefaultHost: boolean; readonly workspacePicker: WorkspacePickerModel; - readonly sessionWorkspaceRecovery?: { readonly sessionId: string }; + readonly sessionWorkspaceRecovery?: SessionWorkspaceRecoveryRequest; readonly canAddProject: boolean; /** Every ready Host's Projects, with the owning Host retained as identity. */ readonly projectScopes: readonly TaskEntryProjectScope[]; @@ -107,18 +110,9 @@ export interface TaskEntryControllerCommands { 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; + closeSessionWorkspaceRecovery(expected?: SessionWorkspaceRecoveryRequest): void; + relocateSessionWorkspace(input: RelocateSessionWorkspaceInput): Promise; + addSessionWorkspace(input: AddSessionWorkspaceInput): Promise; resolveWorkBoardTarget(item: WorkBoardItem): WorkBoardStartTargetResult; prepareWorkBoardDraft(target: TaskEntryTarget, draft: string): string | undefined; /** Reveals the project folder of the task `sessionId`, or the default Host's without one. */ @@ -180,11 +174,10 @@ export function useTaskEntryController( const locale = useUiLocale(); const toast = useToast(); 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, sessions: sessionService, folders } = useTaskEntryServices(); + const { catalog: service, folders } = useTaskEntryServices(); const [catalog, setCatalog] = useState(EMPTY_CATALOG); const [selectedProfileId, setSelectedProfileId] = useState(); const [projectSelections, setProjectSelections] = useState( @@ -193,8 +186,6 @@ 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); @@ -330,78 +321,21 @@ 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; - } - }, [ + const { + sessionWorkspaceRecovery, + openSessionWorkspaceRecovery, closeSessionWorkspaceRecovery, - copy.projectUpdateFailedFallback, - copy.projectUpdateFailedTitle, - locale, + relocateSessionWorkspace, + addSessionWorkspace, + } = useSessionWorkspaceRecovery({ + catalog, + catalogRef: committedCatalogRef, + mutationPendingRef: projectMutationPendingRef, + setPending, + refresh, 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]); + confirm: input.confirm, + }); const addProjectForHost = useCallback(async (host: ReadyTaskEntryHost, name?: string): Promise => { if (projectMutationPendingRef.current) return; @@ -415,28 +349,22 @@ export function useTaskEntryController( projectMutationPendingRef.current = true; setPending(true); try { - let result: TaskEntryProjectMutationResult = await service.addProject( - { - profileId: host.profile.id, - hostId: host.hostId, - }, - name, - ); - if (!result.ok && result.reason === 'archived') { - const confirmed = await (input.confirm ?? toast.confirm)({ + const result = await resolveProjectRegistration({ + register: () => service.addProject({ profileId: host.profile.id, hostId: host.hostId }, name), + confirm: (onConfirm) => (input.confirm ?? toast.confirm)({ + onConfirm, title: copy.archivedProjectTitle, description: copy.archivedProjectDescription, confirmLabel: copy.archivedProjectRestore, cancelLabel: copy.archivedProjectCancel, - }); - if (!confirmed) return; - result = await service.restoreProject( + }), + restore: (projectId) => service.restoreProject( { profileId: host.profile.id, hostId: host.hostId }, - result.projectId, - ); - if (!result.ok) return; - } - if (!result.ok) return; + projectId, + ), + isCurrent: () => true, + }); + if (!result?.ok) return; setSelectedProfileId(host.profile.id); setProjectSelections((current) => new Map(current).set(host.profile.id, result.project.id), @@ -452,7 +380,7 @@ export function useTaskEntryController( projectMutationPendingRef.current = false; setPending(false); } - }, [copy.archivedProjectCancel, copy.archivedProjectDescription, copy.archivedProjectRestore, copy.archivedProjectTitle, copy.readPathFailedFallback, copy.selectDirectoryFailedTitle, locale, refreshAfterProjectMutation, reportError, service, toast]); + }, [copy.archivedProjectCancel, copy.archivedProjectDescription, copy.archivedProjectRestore, copy.archivedProjectTitle, copy.readPathFailedFallback, copy.selectDirectoryFailedTitle, input.confirm, locale, refreshAfterProjectMutation, reportError, service, toast]); const chooseProjectForProfile = useCallback(async (profileId: string): Promise => { let next: TaskEntryCatalog | undefined; diff --git a/apps/desktop/src/renderer/features/task-entry/index.ts b/apps/desktop/src/renderer/features/task-entry/index.ts index daacd288f06..a7bfb11ad15 100644 --- a/apps/desktop/src/renderer/features/task-entry/index.ts +++ b/apps/desktop/src/renderer/features/task-entry/index.ts @@ -17,6 +17,8 @@ * under the License. */ +export { ProjectRegistrationBoundary } from './ui/project-registration-boundary.js'; +export type { ProjectRegistration } from './ui/project-registration-boundary.js'; export { TaskEntryHost } from './ui/task-entry-host.js'; export { TaskEntryRoot, diff --git a/apps/desktop/src/renderer/features/task-entry/locales/session-workspace-recovery-copy.ts b/apps/desktop/src/renderer/features/task-entry/locales/session-workspace-recovery-copy.ts new file mode 100644 index 00000000000..ba05ce82fba --- /dev/null +++ b/apps/desktop/src/renderer/features/task-entry/locales/session-workspace-recovery-copy.ts @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; + +interface SessionWorkspaceRecoveryCopy { + readonly confirmDescription: string; + readonly confirmLabel: string; + readonly projectUnavailable: string; + readonly restoredPrefix: string; + readonly registrationUnconfirmedTitle: string; + readonly registrationUnconfirmed: string; + readonly restoreUnconfirmedTitle: string; + readonly restoreUnconfirmed: string; + readonly moveUnconfirmedTitle: string; + readonly moveUnconfirmed: string; + readonly refreshFailedTitle: string; + readonly refreshFailed: string; +} + +const COPY_BY_LOCALE = { + 'zh-CN': { + confirmDescription: '此目录属于已归档项目。是否恢复原项目,并将当前会话的工作区切换到此项目?不会创建新会话或自动发送消息。', + confirmLabel: '恢复并用于此会话', + projectUnavailable: '项目目录当前不可用,会话工作区未更改。请重新定位目录或选择其他可用项目。', + restoredPrefix: '项目已恢复,未重新归档。', + registrationUnconfirmedTitle: '无法确认项目添加结果', + registrationUnconfirmed: '尚未请求切换会话工作区。请先检查项目列表,再决定是否重试。', + restoreUnconfirmedTitle: '无法确认项目恢复结果', + restoreUnconfirmed: '尚未请求切换会话工作区。请先检查项目是否已恢复,再决定是否重试。', + moveUnconfirmedTitle: '无法确认工作区切换结果', + moveUnconfirmed: '请先检查此会话当前的工作区,再决定是否重试;不会自动再次提交切换。', + refreshFailedTitle: '项目列表刷新失败', + refreshFailed: '刷新失败不会撤销已完成的项目恢复或工作区切换。请刷新项目列表。', + }, + 'zh-TW': { + confirmDescription: '此目錄屬於已封存專案。是否還原原專案,並將目前工作階段的工作區切換至此專案?不會建立新工作階段或自動傳送訊息。', + confirmLabel: '還原並用於此工作階段', + projectUnavailable: '專案目錄目前無法使用,工作階段的工作區未變更。請重新定位目錄或選擇其他可用專案。', + restoredPrefix: '專案已還原,未重新封存。', + registrationUnconfirmedTitle: '無法確認專案新增結果', + registrationUnconfirmed: '尚未請求切換工作階段的工作區。請先檢查專案清單,再決定是否重試。', + restoreUnconfirmedTitle: '無法確認專案還原結果', + restoreUnconfirmed: '尚未請求切換工作階段的工作區。請先檢查專案是否已還原,再決定是否重試。', + moveUnconfirmedTitle: '無法確認工作區切換結果', + moveUnconfirmed: '請先檢查此工作階段目前的工作區,再決定是否重試;不會自動再次提交切換。', + refreshFailedTitle: '專案清單重新整理失敗', + refreshFailed: '重新整理失敗不會撤銷已完成的專案還原或工作區切換。請重新整理專案清單。', + }, + en: { + confirmDescription: 'This directory belongs to an archived project. Restore the original project and use it as this session’s workspace? This will not create a session or send a message.', + confirmLabel: 'Restore and use for this session', + projectUnavailable: 'The project directory is unavailable. The session workspace was not changed. Relocate the directory or choose another available project.', + restoredPrefix: 'The project was restored and has not been archived again.', + registrationUnconfirmedTitle: 'Could not confirm project registration', + registrationUnconfirmed: 'No workspace move was requested. Check the project list before deciding to retry.', + restoreUnconfirmedTitle: 'Could not confirm project restoration', + restoreUnconfirmed: 'No workspace move was requested. Check whether the project was restored before deciding to retry.', + moveUnconfirmedTitle: 'Could not confirm workspace move', + moveUnconfirmed: 'Check this session’s current workspace before deciding to retry. The move will not be submitted again automatically.', + refreshFailedTitle: 'Could not refresh projects', + refreshFailed: 'A refresh failure does not undo a completed project restoration or workspace move. Refresh the project list.', + }, +} satisfies UiCatalog; + +export function getSessionWorkspaceRecoveryCopy(locale: UiLocale): SessionWorkspaceRecoveryCopy { + return COPY_BY_LOCALE[locale]; +} diff --git a/apps/desktop/src/renderer/features/task-entry/ui/project-registration-boundary.tsx b/apps/desktop/src/renderer/features/task-entry/ui/project-registration-boundary.tsx new file mode 100644 index 00000000000..780025cc181 --- /dev/null +++ b/apps/desktop/src/renderer/features/task-entry/ui/project-registration-boundary.tsx @@ -0,0 +1,94 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'; +import { useToast, useUiLocale } from '@maka/ui'; +import { getShellCopy } from '../../../locales/shell-copy.js'; +import type { TaskEntryHostRef, TaskEntryProjectMutationResult } from '../ports.js'; +import { useTaskEntryServices } from '../services-context.js'; +import { resolveProjectRegistration } from '../controller/resolve-project-registration.js'; + +export interface ProjectRegistration { + readonly pending: boolean; + register( + operation: () => Promise, + isCurrent?: () => boolean, + ): Promise; +} + +/** Owns only the single-flight registration/archived-project recovery transaction. */ +export function ProjectRegistrationBoundary(props: { + host?: TaskEntryHostRef; + children(registration: ProjectRegistration): ReactNode; +}) { + const { catalog } = useTaskEntryServices(); + const toast = useToast(); + const locale = useUiLocale(); + const copy = getShellCopy(locale).projectActions; + const [pending, setPending] = useState(false); + const generation = useRef(0); + const inFlight = useRef(false); + const mounted = useRef(false); + + useLayoutEffect(() => { + mounted.current = true; + generation.current += 1; + inFlight.current = false; + setPending(false); + return () => { + mounted.current = false; + generation.current += 1; + }; + }, [props.host?.profileId, props.host?.hostId, catalog, locale]); + + async function register( + operation: () => Promise, + isCurrent: () => boolean = () => true, + ): Promise { + const host = props.host; + if (!host || !mounted.current || inFlight.current || !isCurrent()) return; + const sequence = generation.current; + const current = () => mounted.current && generation.current === sequence && isCurrent(); + inFlight.current = true; + setPending(true); + try { + return await resolveProjectRegistration({ + register: operation, + confirm: (onConfirm) => toast.confirm({ + onConfirm, + title: copy.archivedProjectTitle, + description: copy.archivedProjectDescription, + confirmLabel: copy.archivedProjectRestore, + cancelLabel: copy.archivedProjectCancel, + }), + restore: (projectId) => catalog.restoreProject(host, projectId), + isCurrent: current, + }); + } catch (cause) { + if (current()) throw cause; + } finally { + if (mounted.current && generation.current === sequence) { + inFlight.current = false; + setPending(false); + } + } + } + + return props.children({ pending, register }); +} 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 57c6ee6789c..223d737e041 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 @@ -144,8 +144,9 @@ function createTaskEntryOwner(): TaskEntryOwner & { current.commands.chooseProjectForProfile(profileId), openSessionWorkspaceRecovery: (sessionId: string) => current.commands.openSessionWorkspaceRecovery(sessionId), - closeSessionWorkspaceRecovery: () => - current.commands.closeSessionWorkspaceRecovery(), + closeSessionWorkspaceRecovery: ( + expected?: Parameters[0], + ) => current.commands.closeSessionWorkspaceRecovery(expected), relocateSessionWorkspace: ( input: Parameters[0], ) => @@ -371,6 +372,16 @@ export function TaskEntryWorkspacePickerConsumer({ useEffect(() => { setRecoveryMenuOpen(recoveryRequested); }, [recovery, recoveryRequested]); + useLayoutEffect(() => { + if (!recovery) return; + if (!activeSession || recovery.sessionId !== activeSession.id) { + owner.commands.closeSessionWorkspaceRecovery(recovery); + return; + } + // The dropdown may close to open New project; only leaving the Session/Host + // (or replacing this recovery request) invalidates that handoff. + return () => owner.commands.closeSessionWorkspaceRecovery(recovery); + }, [activeSession?.id, activeSession?.profileId, activeSession?.runtimeHostId, owner, recovery]); const workspacePicker = useMemo( () => { const defaultPicker: WorkspacePickerModel = { @@ -383,7 +394,7 @@ export function TaskEntryWorkspacePickerConsumer({ if (!activeSession || !recoveryRequested) return defaultPicker; const activeGroup = controllerPicker.groups.find( - (group) => group.hostId === activeSession.runtimeHostId, + (group) => group.id === activeSession.profileId && group.hostId === activeSession.runtimeHostId, ); const activeProject = activeGroup?.projects.find( (project) => @@ -409,6 +420,7 @@ export function TaskEntryWorkspacePickerConsumer({ sessionId: activeSession.id, profileId: activeSession.profileId, projectId, + request: recovery, }), onAdd: activeSession.profileKind === 'local' && activeGroup.hostId @@ -417,6 +429,7 @@ export function TaskEntryWorkspacePickerConsumer({ profileId: activeSession.profileId, host: { profileId: activeGroup.id, hostId: activeGroup.hostId! }, name, + request: recovery, }) : undefined, onRelink: undefined, @@ -434,6 +447,7 @@ export function TaskEntryWorkspacePickerConsumer({ manageProjects, owner, recoveryMenuOpen, + recovery, recoveryRequested, ], ); diff --git a/apps/desktop/src/renderer/remote-project-directory-dialog.tsx b/apps/desktop/src/renderer/remote-project-directory-dialog.tsx index 37c19d84162..8c157b8bf1e 100644 --- a/apps/desktop/src/renderer/remote-project-directory-dialog.tsx +++ b/apps/desktop/src/renderer/remote-project-directory-dialog.tsx @@ -25,7 +25,8 @@ import { DropdownMenu, DropdownMenuItem } from '@astryxdesign/core/DropdownMenu' import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; import { HStack } from '@astryxdesign/core/Stack'; import { Text } from '@astryxdesign/core/Text'; -import { useToast, useUiLocale } from '@maka/ui'; +import { useUiLocale } from '@maka/ui'; +import { ProjectRegistrationBoundary, type ProjectRegistration } from './features/task-entry/index.js'; import { reportUnexpectedError } from './application/contracts/operation-diagnostics.js'; import { Check, Eye, EyeOff, FolderOpen } from '@maka/ui/icons'; import type { @@ -53,7 +54,6 @@ export function RemoteProjectDirectoryDialog(props: { onRegistered(project: ProjectRecord, host: DesktopRuntimeHostRef): void; }) { const locale = useUiLocale(); - const toast = useToast(); const copy = getShellCopy(locale).projectActions; const [roots, setRoots] = useState([]); const [root, setRoot] = useState(); @@ -157,30 +157,25 @@ export function RemoteProjectDirectoryDialog(props: { void load({ kind: 'directory', host, root: nextRoot, segments: [] }); } - async function register(): Promise { + async function register(registration: ProjectRegistration): Promise { const host = props.host; - if (!host || !root || loading || registering) return; - const sequence = ++request.current; - setRegistering(true); - setError(undefined); + if (!host || !root || loading || registering || registration.pending) return; + let sequence: number | undefined; try { - let project = await window.maka.projects.registerDirectory({ - rootId: root.id, - segments, - }, host); - if (request.current !== sequence) return; - if (project.archivedAt !== undefined) { - const confirmed = await toast.confirm({ - title: copy.archivedProjectTitle, - description: copy.archivedProjectDescription, - confirmLabel: copy.archivedProjectRestore, - cancelLabel: copy.archivedProjectCancel, - }); - if (!confirmed || request.current !== sequence) return; - project = await window.maka.projects.restore(project.id, host); - if (request.current !== sequence) return; - } - props.onRegistered(project, host); + const result = await registration.register(async () => { + sequence = ++request.current; + setRegistering(true); + setError(undefined); + const project = await window.maka.projects.registerDirectory({ + rootId: root.id, + segments, + }, host); + return project.archivedAt === undefined + ? { ok: true, project } + : { ok: false, reason: 'archived', projectId: project.id }; + }, () => sequence === undefined || request.current === sequence); + if (request.current !== sequence || !result?.ok) return; + props.onRegistered(result.project, host); } catch (cause) { if (request.current !== sequence) return; reportUnexpectedError('project-directory:register', cause); @@ -209,6 +204,8 @@ export function RemoteProjectDirectoryDialog(props: { ? entries : entries.filter((entry) => !entry.name.startsWith('.')); return ( + + {(registration) => ( { @@ -338,7 +335,7 @@ export function RemoteProjectDirectoryDialog(props: { label={copy.remoteDirectorySelect} isDisabled={!root || loading || registering} isLoading={registering} - onClick={() => void register()} + onClick={() => void register(registration)} /> @@ -346,6 +343,8 @@ export function RemoteProjectDirectoryDialog(props: { } /> + )} + ); } diff --git a/apps/desktop/src/renderer/settings/projects-settings-page.tsx b/apps/desktop/src/renderer/settings/projects-settings-page.tsx index 140da485998..7777e3384dc 100644 --- a/apps/desktop/src/renderer/settings/projects-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/projects-settings-page.tsx @@ -43,6 +43,7 @@ import { RuntimeHostProfilesSection } from './runtime-host-profiles-section.js'; import { useKeyedActionGuard } from './use-action-guard'; import { useOptionalRuntimeHostSettingsTarget } from './runtime-host-settings-target.js'; import { getSettingsSharedCopy } from '../locales/settings-shared-copy.js'; +import { ProjectRegistrationBoundary, type ProjectRegistration } from '../features/task-entry/index.js'; import { RemoteProjectDirectoryDialog } from '../remote-project-directory-dialog.js'; import { RuntimeHostInteractionBoundary } from './runtime-host-interaction-boundary.js'; @@ -92,7 +93,6 @@ export function ProjectsSettingsPage(props: { const [homePath, setHomePath] = useState(undefined); const [renamingId, setRenamingId] = useState(null); const [draftName, setDraftName] = useState(''); - const [adding, setAdding] = useState(false); const [projectDialog, setProjectDialog] = useState<'directory' | 'new' | null>(null); const directoryPickerTriggerRef = useRef(null); const reloadGeneration = useRef(0); @@ -112,26 +112,15 @@ export function ProjectsSettingsPage(props: { * call still owns the folder picker, so a cancelled picker simply leaves the * list unchanged; the name rides along and the project is registered under it. */ - async function addNamedProject(name: string) { + async function addNamedProject(name: string, registration: ProjectRegistration) { if (!host || !props.runtimeHostTargetVerified) return; await runRowAction('add', async () => { - setAdding(true); - try { - const result = await window.maka.projects.add(host, { name }); - if (!mountedRef.current) return; - if (!result.ok && result.reason === 'archived') { - const ok = await toast.confirm({ - title: copy.archivedProjectTitle, - description: copy.archivedProjectDescription, - confirmLabel: copy.archivedProjectRestore, - cancelLabel: copy.archivedProjectCancel, - }); - if (!ok || !mountedRef.current) return; - await window.maka.projects.restore(result.projectId, host); - } - } finally { - if (mountedRef.current) setAdding(false); - } + const result = await registration.register( + () => window.maka.projects.add(host, { name }), + () => mountedRef.current, + ); + // Cancelled or invalidated registrations must not trigger a refresh. + if (!result?.ok) return false; }, copy.actionFailed); } @@ -182,7 +171,7 @@ export function ProjectsSettingsPage(props: { async function runRowAction( key: string, - action: () => Promise, + action: () => Promise, failure: string, ) { if (!props.runtimeHostTargetVerified) return; @@ -190,7 +179,7 @@ export function ProjectsSettingsPage(props: { if (!release) return; try { try { - await action(); + if (await action() === false) return; } catch (error) { if (mountedRef.current) { toast.error( @@ -259,6 +248,8 @@ export function ProjectsSettingsPage(props: { ); } return ( + + {(registration) => ( { if (props.runtimeHostTargetVerified) setProjectDialog('directory'); @@ -538,11 +529,13 @@ export function ProjectsSettingsPage(props: { if (!open) setProjectDialog(null); }} onSubmit={(name) => { - void addNamedProject(name); + void addNamedProject(name, registration); }} /> ) : null} + )} + ); } diff --git a/apps/desktop/stories/accessibility-runtime-surfaces.stories.tsx b/apps/desktop/stories/accessibility-runtime-surfaces.stories.tsx index c0a7d1049d4..802e0a229fd 100644 --- a/apps/desktop/stories/accessibility-runtime-surfaces.stories.tsx +++ b/apps/desktop/stories/accessibility-runtime-surfaces.stories.tsx @@ -32,6 +32,8 @@ import { type WorkbarServices, } from '../src/renderer/features/workbar/testing'; import { WorkbarSurface } from '../src/renderer/features/workbar/stories'; +import { TaskEntryServicesProvider } from '../src/renderer/features/task-entry'; +import { createFakeTaskEntryServices } from '../src/renderer/features/task-entry/testing'; import { RemoteProjectDirectoryDialog } from '../src/renderer/remote-project-directory-dialog'; import { RuntimeHostSshTerminalDialog } from '../src/renderer/settings/runtime-host-ssh-terminal-dialog'; import { withScopedMakaBridge } from './maka-bridge'; @@ -250,11 +252,14 @@ const htmlArtifact: ArtifactDescriptor = { source: 'subagent_writeback', }; +const taskEntryServices = createFakeTaskEntryServices(); + function RemoteProjectDirectoryStory() { const [open, setOpen] = useState(false); const triggerRef = useRef(null); return ( - <> + + @@ -266,7 +271,8 @@ function RemoteProjectDirectoryStory() { onClose={() => setOpen(false)} onRegistered={() => setOpen(false)} /> - + + ); } @@ -356,6 +362,7 @@ export const BackgroundArtifactCount: Story = { }, }; +// Real path: Project Settings → choose a remote Host directory → register and reopen. export const RemoteProjectDirectory: Story = { decorators: [ withScopedMakaBridge({ diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index 34262a0e521..f305b1dbf71 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -76,6 +76,8 @@ import { createDefaultSettings, mergeSettings } from '@maka/core/settings'; import { DEFAULT_DAILY_REVIEW_CONFIG } from '@maka/core/daily-review'; import type { PetPackManifestV1 } from '@maka/core/pet'; import { SettingsSurface } from '../../src/renderer/settings/settings-surface'; +import { TaskEntryServicesProvider } from '../../src/renderer/features/task-entry'; +import { createFakeTaskEntryServices } from '../../src/renderer/features/task-entry/testing'; import { ConnectionSettingsServicesProvider } from '../../src/renderer/features/connection-settings'; import { RuntimeHostManagementServicesProvider } from '../../src/renderer/features/runtime-host-management'; import { @@ -142,6 +144,7 @@ const noop = () => undefined; // Both halves open a native file dialog, which a story has none of. Cancelled is // the outcome that leaves the page exactly as it was. +const taskEntryServices = createFakeTaskEntryServices(); const sessionBundleServices: SessionBundleServices = { exportBundle: async () => ({ ok: false, reason: 'canceled' }), importBundle: async () => ({ ok: false, reason: 'canceled' }), @@ -2001,7 +2004,9 @@ function SettingsStory(props: SettingsStoryProps) { - + + + diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 3fa17e25700..751264c6660 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.6.3` (195 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 331 files — blocker 0, reimplementation 0, polish 4, aligned 327. +**Totals:** 332 files — blocker 0, reimplementation 0, polish 4, aligned 328. ## Exclusions (explicit) @@ -130,6 +130,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/storage-usage/ui/storage-usage-section.tsx` | other | Banner, Button | aligned — uses Astryx (Banner, Button) | aligned | | `apps/desktop/src/renderer/features/storage-usage/ui/task-storage-size.tsx` | other | Text | aligned — uses Astryx (Text) | aligned | | `apps/desktop/src/renderer/features/task-entry/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/task-entry/ui/project-registration-boundary.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/usage/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index ea0a82ee5ac..e7ad58de55d 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -100,6 +100,7 @@ apps/desktop/src/renderer/features/storage-usage/ui/archive-retention-section.ts apps/desktop/src/renderer/features/storage-usage/ui/storage-usage-section.tsx apps/desktop/src/renderer/features/storage-usage/ui/task-storage-size.tsx apps/desktop/src/renderer/features/task-entry/services-context.tsx +apps/desktop/src/renderer/features/task-entry/ui/project-registration-boundary.tsx apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx apps/desktop/src/renderer/features/usage/services-context.tsx diff --git a/packages/ui/src/toast.tsx b/packages/ui/src/toast.tsx index 39474f188ca..87249bbc22d 100644 --- a/packages/ui/src/toast.tsx +++ b/packages/ui/src/toast.tsx @@ -107,6 +107,8 @@ export interface ConfirmInput { confirmLabel?: string; cancelLabel?: string; destructive?: boolean; + /** Keep the confirmation modal open while the accepted operation runs. */ + onConfirm?(): Promise; } export interface ToastApi { @@ -127,12 +129,15 @@ export interface ToastApi { interface PendingConfirm extends ConfirmInput { id: string; resolve(result: boolean): void; + reject(cause: unknown): void; + running?: boolean; } interface ActiveConfirm { request: PendingConfirm; - phase: 'mounting' | 'open' | 'closing'; + phase: 'mounting' | 'open' | 'pending' | 'closing'; result?: boolean; + failure?: { cause: unknown }; } const DEFAULT_DURATION = 4000; @@ -249,8 +254,8 @@ function ToastController(props: { children: ReactNode; errorAction?: ToastErrorA }, []); const confirm = useCallback((input: ConfirmInput): Promise => { - return new Promise((resolve) => { - const request: PendingConfirm = { id: `c${++idSeed.current}`, ...input, resolve }; + return new Promise((resolve, reject) => { + const request: PendingConfirm = { id: `c${++idSeed.current}`, ...input, resolve, reject }; if (activeConfirmRef.current) { confirmQueueRef.current.push(request); return; @@ -263,7 +268,25 @@ function ToastController(props: { children: ReactNode; errorAction?: ToastErrorA const requestConfirmResult = useCallback( (result: boolean) => { const current = activeConfirmRef.current; - if (!current) return; + if (!current || current.running) return; + const onConfirm = current.onConfirm; + if (result && onConfirm) { + current.running = true; + setConfirmState((state) => state?.request === current + ? { ...state, phase: 'pending' } : state); + void (async () => { + let failure: ActiveConfirm['failure']; + try { + await onConfirm(); + } catch (cause) { + failure = { cause }; + } + if (activeConfirmRef.current !== current) return; + setConfirmState((state) => state?.request === current + ? { ...state, phase: 'closing', result: true, failure } : state); + })(); + return; + } setConfirmState((state) => { if ( !state || @@ -296,7 +319,8 @@ function ToastController(props: { children: ReactNode; errorAction?: ToastErrorA const current = activeConfirmRef.current; if (!current || current !== confirmState.request) return; activeConfirmRef.current = null; - current.resolve(confirmState.result ?? false); + if (confirmState.failure) current.reject(confirmState.failure.cause); + else current.resolve(confirmState.result ?? false); const next = confirmQueueRef.current.shift() ?? null; activeConfirmRef.current = next; setConfirmState( @@ -347,7 +371,8 @@ function ToastController(props: { children: ReactNode; errorAction?: ToastErrorA )} @@ -416,6 +441,7 @@ function ToastBody({ input }: { input: ToastInput }) { function ConfirmDialog(props: { request: PendingConfirm; isOpen: boolean; + pending: boolean; onResolve(result: boolean): void; }) { const copy = getSharedUiCopy(useUiLocale()).toast; @@ -438,6 +464,8 @@ function ConfirmDialog(props: { description={description ?? ''} cancelLabel={cancelLabel} actionLabel={confirmLabel} + isActionLoading={props.pending} + aria-busy={props.pending} actionVariant={destructive ? 'destructive' : 'primary'} onAction={() => props.onResolve(true)} /> From 6e2f8c6ab8436c5041eb1829b9f92828df82357c Mon Sep 17 00:00:00 2001 From: liuzhaochen03 Date: Thu, 8 Oct 2026 11:54:33 +0800 Subject: [PATCH 4/4] fix(desktop): preserve archived project names during remote registration Extract shared project registration recovery into a hook and carry the restored result through the remote directory handoff. Align workspace recovery copy with task terminology and expose archived add results in the preload bridge. Generated-by: Codex --- .../__tests__/project-add-restore.test.ts | 9 +- .../__tests__/task-entry-controller.test.ts | 36 +++++++- .../main/__tests__/workbar-controller.test.ts | 16 ++-- apps/desktop/src/preload/preload.ts | 4 +- .../renderer/features/task-entry/README.md | 11 +-- .../resolve-project-registration.ts | 9 +- .../controller/use-project-registration.ts | 90 +++++++++++++++++++ .../controller/use-task-entry-controller.ts | 4 +- .../src/renderer/features/task-entry/index.ts | 2 +- .../session-workspace-recovery-copy.ts | 48 +++++----- .../ui/project-registration-boundary.tsx | 75 ++-------------- .../task-entry/ui/task-entry-host.tsx | 5 +- .../locales/settings-projects-copy.ts | 16 ---- .../remote-project-directory-dialog.tsx | 4 +- 14 files changed, 194 insertions(+), 135 deletions(-) create mode 100644 apps/desktop/src/renderer/features/task-entry/controller/use-project-registration.ts diff --git a/apps/desktop/src/main/__tests__/project-add-restore.test.ts b/apps/desktop/src/main/__tests__/project-add-restore.test.ts index f8f9c844f08..1ef2b9b0920 100644 --- a/apps/desktop/src/main/__tests__/project-add-restore.test.ts +++ b/apps/desktop/src/main/__tests__/project-add-restore.test.ts @@ -35,7 +35,7 @@ interface RenderModules { RemoteProjectDirectoryDialog: ComponentType<{ host?: DesktopRuntimeHostRef; onClose(): void; - onRegistered(project: ProjectRecord, host: DesktopRuntimeHostRef): void; + onRegistered(project: ProjectRecord, host: DesktopRuntimeHostRef, restored: boolean): void; }>; SettingsFixture: ComponentType<{ host?: DesktopRuntimeHostRef; verified?: boolean }>; SessionRecoveryFixture: ComponentType<{ sessionId?: string }>; @@ -208,7 +208,7 @@ for (const action of ['submit', 'cancel'] as const) { } else { await submitProjectName(); assert.deepEqual(calls, ['add']); - await click('Restore and use for this session'); + await click('Restore and use for this task'); assert.deepEqual(calls, ['add', 'restore', 'relocate:session-1:p']); } }); @@ -219,6 +219,7 @@ for (const outcome of ['restore', 'cancel', 'failure', 'normal'] as const) { const harness = installRenderer(); const accepted: ProjectRecord[] = []; const restores: unknown[][] = []; + const restorationFlags: boolean[] = []; bridge({ registerDirectory: async () => outcome === 'normal' ? restored : archived, }); @@ -229,7 +230,7 @@ for (const outcome of ['restore', 'cancel', 'failure', 'normal'] as const) { }; const render = (target: DesktopRuntimeHostRef | undefined = host) => harness.render( createElement(components.RemoteProjectDirectoryDialog, { - host: target, onClose() {}, onRegistered(project) { accepted.push(project); }, + host: target, onClose() {}, onRegistered(project, _host, wasRestored) { accepted.push(project); restorationFlags.push(wasRestored); }, }), ); await render(); @@ -237,6 +238,7 @@ for (const outcome of ['restore', 'cancel', 'failure', 'normal'] as const) { if (outcome === 'normal') { assert.deepEqual(accepted, [restored]); assert.equal(restores.length, 0); + assert.deepEqual(restorationFlags, [false]); return; } assert.equal(accepted.length, 0, 'must not accept an archived project before confirmation'); @@ -252,6 +254,7 @@ for (const outcome of ['restore', 'cancel', 'failure', 'normal'] as const) { if (outcome === 'restore') { assert.deepEqual(restores, [[host, 'p']]); assert.deepEqual(accepted, [restored]); + assert.deepEqual(restorationFlags, [true]); } else { assert.equal(accepted.length, 0); assert.equal(restores.length, outcome === 'failure' ? 1 : 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 d8c08754586..f0954382fae 100644 --- a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts +++ b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts @@ -216,8 +216,8 @@ describe('Session workspace archived-project recovery', () => { it('confirms use for this Session, restores original identity, then relocates without selecting a new-task Project', async () => { const harness = await sessionRecoveryHarness({ confirm: async (input) => { - assert.equal(input.confirmLabel, 'Restore and use for this session'); - assert.match(input.description ?? '', /this session’s workspace/); + assert.equal(input.confirmLabel, 'Restore and use for this task'); + assert.match(input.description ?? '', /this task’s workspace/); return true; }, }); @@ -309,7 +309,7 @@ describe('Session workspace archived-project recovery', () => { assert.equal(controller().selectors.sessionWorkspaceRecovery, undefined); assert.equal(controller().selectors.workspacePicker.pending, false); assert.equal(harness.errors.length, 1); - assert.match(JSON.stringify(harness.errors), /Could not refresh projects/); + assert.match(JSON.stringify(harness.errors), /Could not refresh the project list/); assert.doesNotMatch(JSON.stringify(harness.errors), /Could not move|Could not confirm/); }); @@ -775,6 +775,36 @@ describe('useTaskEntryController', () => { assert.equal(reads, 2); }); + for (const restored of [false, true]) { + it(`remote directory registration ${restored ? 'preserves a restored name' : 'applies the requested name'}`, async () => { + const { root } = installReactRenderer(); + const original = { ...project('project-b'), name: 'Original name' }; + const remote = { ...readyRemoteHost('generation-a'), projects: [original] }; + const renames: string[] = []; + const services = createFakeTaskEntryServices({ + catalog: { + ...createFakeTaskEntryServices().catalog, + getCatalog: async () => ({ defaultProfileId: 'remote', hosts: [remote] }), + renameProject: async (_host, _projectId, name) => { + renames.push(name); + original.name = name; + }, + }, + }); + await act(async () => renderController(root, services)); + await act(async () => controller().commands.addProject('Requested name')); + await act(async () => controller().host.acceptRegisteredProject( + original, + { profileId: 'remote', hostId: 'generation-a' }, + restored, + )); + assert.deepEqual(renames, restored ? [] : ['Requested name']); + assert.equal(controller().selectors.target?.projectId, original.id); + assert.equal(controller().selectors.projectScopes.find((scope) => scope.project.id === original.id)?.project.name, restored ? 'Original name' : 'Requested name'); + assert.equal(controller().host.directoryHost, undefined); + }); + } + it('closes a remote directory handoff when the Host generation changes', async () => { const { root } = installReactRenderer(); let reads = 0; diff --git a/apps/desktop/src/main/__tests__/workbar-controller.test.ts b/apps/desktop/src/main/__tests__/workbar-controller.test.ts index 14a84da959f..c7f414d077a 100644 --- a/apps/desktop/src/main/__tests__/workbar-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-controller.test.ts @@ -26,7 +26,7 @@ import { act, createElement, StrictMode, useLayoutEffect } from 'react'; import type { ShellRunUpdate } from '@maka/core/events'; import type { SessionSummary } from '@maka/core/session'; import type { WorkBoardActiveItem, WorkBoardItem, WorkBoardLinkedSession } from '@maka/core/work-board'; -import { LocaleProvider, type ToastApi } from '@maka/ui'; +import { LocaleProvider, ToastProvider, type ToastApi } from '@maka/ui'; import { cleanupFakeDom, fakeMediaQueryMatches, @@ -315,12 +315,16 @@ function renderWorkBoardComposition( createElement(LocaleProvider, { locale: 'en', children: createElement( - TaskEntryServicesProvider, - { services: taskEntryServices }, + ToastProvider, + null, createElement( - WorkbarServicesProvider, - { services: workbarServices }, - createElement(WorkHubEnablementProvider, { value: WORKHUB_OFF }, createElement(WorkBoardCompositionProbe, { ownerRef })), + TaskEntryServicesProvider, + { services: taskEntryServices }, + createElement( + WorkbarServicesProvider, + { services: workbarServices }, + createElement(WorkHubEnablementProvider, { value: WORKHUB_OFF }, createElement(WorkBoardCompositionProbe, { ownerRef })), + ), ), ), }), diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 0239f3caf3d..20385b932ea 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -3026,7 +3026,9 @@ const makaBridge = { }); }, add(host?: DesktopRuntimeHostRef, options?: { name?: string }): Promise< - { ok: true; project: ProjectRecord; path: string } | { ok: false; reason: 'cancelled' } + | { ok: true; project: ProjectRecord; path: string } + | { ok: false; reason: 'cancelled' } + | { ok: false; reason: 'archived'; projectId: string } > { return invokeSelectedRuntimeHost(host, 'projects:add', options); }, diff --git a/apps/desktop/src/renderer/features/task-entry/README.md b/apps/desktop/src/renderer/features/task-entry/README.md index 3a00b203a14..26f68224c5a 100644 --- a/apps/desktop/src/renderer/features/task-entry/README.md +++ b/apps/desktop/src/renderer/features/task-entry/README.md @@ -66,18 +66,19 @@ workspace-unavailable notice instead. - Draft identity is target-scoped. An unresolved catalog always uses the stable unresolved new-task key, preserving reload and target-switch handoff behavior. -## Registration recovery boundary +## Registration recovery -`ProjectRegistrationBoundary` provides a render-prop transaction for legacy -registration callers. It owns single-flight pending state, archived-project -confirmation, and recovery through the injected catalog. Cancellation is silent; +`useProjectRegistration` owns single-flight pending state, archived-project +confirmation, and recovery through the injected catalog. +`ProjectRegistrationBoundary` is a thin render-prop adapter for legacy callers, +whose architecture contract forbids adding hook calls. Cancellation is silent; failures return to the caller's existing error surface. Host changes, lost Host verification, locale changes, unmount, and caller request fences prevent stale confirmation or recovery results from continuing. Project Settings keeps its original folder-picker add operation, row-action errors, and refresh. The shared remote-directory browser keeps directory I/O, -navigation, request sequencing, and accepted-project handoff. This boundary does +navigation, request sequencing, and accepted-project handoff. The hook does not take ownership of either surface's broader lifecycle. ## Existing Session workspace recovery diff --git a/apps/desktop/src/renderer/features/task-entry/controller/resolve-project-registration.ts b/apps/desktop/src/renderer/features/task-entry/controller/resolve-project-registration.ts index 8e94ec497f5..fab07b939f9 100644 --- a/apps/desktop/src/renderer/features/task-entry/controller/resolve-project-registration.ts +++ b/apps/desktop/src/renderer/features/task-entry/controller/resolve-project-registration.ts @@ -17,15 +17,20 @@ * under the License. */ +import type { ProjectRecord } from '@maka/core/project'; import type { TaskEntryProjectMutationResult } from '../ports.js'; +export type ProjectRegistrationResult = + | { readonly ok: true; readonly project: ProjectRecord; readonly restored?: true } + | Exclude; + /** Resolves registration without deciding how the caller uses the Project. */ export async function resolveProjectRegistration(input: { register(): Promise; confirm(onConfirm: () => Promise): Promise; restore(projectId: string): Promise; isCurrent(): boolean; -}): Promise { +}): Promise { if (!input.isCurrent()) return; const result = await input.register(); if (!input.isCurrent()) return; @@ -41,5 +46,5 @@ export async function resolveProjectRegistration(input: { if (!restored.ok && restored.reason === 'archived') { throw new Error('Project remains archived after restore'); } - return restored; + return restored.ok ? { ...restored, restored: true } : restored; } diff --git a/apps/desktop/src/renderer/features/task-entry/controller/use-project-registration.ts b/apps/desktop/src/renderer/features/task-entry/controller/use-project-registration.ts new file mode 100644 index 00000000000..9b1e12597c8 --- /dev/null +++ b/apps/desktop/src/renderer/features/task-entry/controller/use-project-registration.ts @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useLayoutEffect, useRef, useState } from 'react'; +import { useToast, useUiLocale } from '@maka/ui'; +import { getShellCopy } from '../../../locales/shell-copy.js'; +import type { TaskEntryHostRef, TaskEntryProjectMutationResult } from '../ports.js'; +import { useTaskEntryServices } from '../services-context.js'; +import { resolveProjectRegistration, type ProjectRegistrationResult } from './resolve-project-registration.js'; + +export interface ProjectRegistration { + readonly pending: boolean; + register( + operation: () => Promise, + isCurrent?: () => boolean, + ): Promise; +} + +/** Owns only the single-flight registration/archived-project recovery transaction. */ +export function useProjectRegistration(host?: TaskEntryHostRef): ProjectRegistration { + const { catalog } = useTaskEntryServices(); + const toast = useToast(); + const locale = useUiLocale(); + const copy = getShellCopy(locale).projectActions; + const [pending, setPending] = useState(false); + const generation = useRef(0); + const inFlight = useRef(false); + const mounted = useRef(false); + + useLayoutEffect(() => { + mounted.current = true; + generation.current += 1; + inFlight.current = false; + setPending(false); + return () => { + mounted.current = false; + generation.current += 1; + }; + }, [host?.profileId, host?.hostId, catalog, locale]); + + async function register( + operation: () => Promise, + isCurrent: () => boolean = () => true, + ): Promise { + if (!host || !mounted.current || inFlight.current || !isCurrent()) return; + const sequence = generation.current; + const current = () => mounted.current && generation.current === sequence && isCurrent(); + inFlight.current = true; + setPending(true); + try { + return await resolveProjectRegistration({ + register: operation, + confirm: (onConfirm) => toast.confirm({ + onConfirm, + title: copy.archivedProjectTitle, + description: copy.archivedProjectDescription, + confirmLabel: copy.archivedProjectRestore, + cancelLabel: copy.archivedProjectCancel, + }), + restore: (projectId) => catalog.restoreProject(host, projectId), + isCurrent: current, + }); + } catch (cause) { + if (current()) throw cause; + } finally { + if (mounted.current && generation.current === sequence) { + inFlight.current = false; + setPending(false); + } + } + } + + return { pending, register }; +} 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 e1b049b6352..e5ac59c2881 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 @@ -415,6 +415,7 @@ export function useTaskEntryController( const acceptRegisteredProject = useCallback(async ( project: ProjectRecord, registeredHost: TaskEntryHostRef, + restored = false, ): Promise => { const host = directoryHost; if ( @@ -428,7 +429,8 @@ export function useTaskEntryController( // has no name field of its own — so the name typed before the folder was // picked is applied here. A failed rename must not lose the project that was // just created, so it falls back to the folder-derived name. - if (host.projectName) { + // Restoring preserves the archived project's name, matching local Add. + if (host.projectName && !restored) { await service .renameProject(registeredHost, project.id, host.projectName) .catch(() => undefined); diff --git a/apps/desktop/src/renderer/features/task-entry/index.ts b/apps/desktop/src/renderer/features/task-entry/index.ts index a7bfb11ad15..79f3c0bad1c 100644 --- a/apps/desktop/src/renderer/features/task-entry/index.ts +++ b/apps/desktop/src/renderer/features/task-entry/index.ts @@ -18,7 +18,7 @@ */ export { ProjectRegistrationBoundary } from './ui/project-registration-boundary.js'; -export type { ProjectRegistration } from './ui/project-registration-boundary.js'; +export type { ProjectRegistration } from './controller/use-project-registration.js'; export { TaskEntryHost } from './ui/task-entry-host.js'; export { TaskEntryRoot, diff --git a/apps/desktop/src/renderer/features/task-entry/locales/session-workspace-recovery-copy.ts b/apps/desktop/src/renderer/features/task-entry/locales/session-workspace-recovery-copy.ts index ba05ce82fba..a443be91ef9 100644 --- a/apps/desktop/src/renderer/features/task-entry/locales/session-workspace-recovery-copy.ts +++ b/apps/desktop/src/renderer/features/task-entry/locales/session-workspace-recovery-copy.ts @@ -36,46 +36,46 @@ interface SessionWorkspaceRecoveryCopy { const COPY_BY_LOCALE = { 'zh-CN': { - confirmDescription: '此目录属于已归档项目。是否恢复原项目,并将当前会话的工作区切换到此项目?不会创建新会话或自动发送消息。', - confirmLabel: '恢复并用于此会话', - projectUnavailable: '项目目录当前不可用,会话工作区未更改。请重新定位目录或选择其他可用项目。', + confirmDescription: '此目录属于一个已归档的项目。是否恢复原项目,并将当前任务的工作区切换到该项目目录?不会创建新任务或自动发送消息。', + confirmLabel: '恢复并用于当前任务', + projectUnavailable: '项目目录当前不可用,当前任务的工作区未更改。请重新选择项目目录或选择其他可用项目。', restoredPrefix: '项目已恢复,未重新归档。', registrationUnconfirmedTitle: '无法确认项目添加结果', - registrationUnconfirmed: '尚未请求切换会话工作区。请先检查项目列表,再决定是否重试。', + registrationUnconfirmed: '尚未发起当前任务的工作区切换。请先检查项目列表,再决定是否重试。', restoreUnconfirmedTitle: '无法确认项目恢复结果', - restoreUnconfirmed: '尚未请求切换会话工作区。请先检查项目是否已恢复,再决定是否重试。', + restoreUnconfirmed: '尚未发起当前任务的工作区切换。请先检查项目是否已恢复,再决定是否重试。', moveUnconfirmedTitle: '无法确认工作区切换结果', - moveUnconfirmed: '请先检查此会话当前的工作区,再决定是否重试;不会自动再次提交切换。', + moveUnconfirmed: '请先检查当前任务使用的工作区,再决定是否重试;不会自动再次提交切换。', refreshFailedTitle: '项目列表刷新失败', refreshFailed: '刷新失败不会撤销已完成的项目恢复或工作区切换。请刷新项目列表。', }, 'zh-TW': { - confirmDescription: '此目錄屬於已封存專案。是否還原原專案,並將目前工作階段的工作區切換至此專案?不會建立新工作階段或自動傳送訊息。', - confirmLabel: '還原並用於此工作階段', - projectUnavailable: '專案目錄目前無法使用,工作階段的工作區未變更。請重新定位目錄或選擇其他可用專案。', - restoredPrefix: '專案已還原,未重新封存。', + confirmDescription: '此目錄屬於一個已歸檔的專案。是否恢復原專案,並將目前任務的工作區切換至該專案目錄?不會建立新任務或自動傳送訊息。', + confirmLabel: '恢復並用於目前任務', + projectUnavailable: '專案目錄目前無法使用,目前任務的工作區未變更。請重新選擇專案目錄或選擇其他可用專案。', + restoredPrefix: '專案已恢復,未重新歸檔。', registrationUnconfirmedTitle: '無法確認專案新增結果', - registrationUnconfirmed: '尚未請求切換工作階段的工作區。請先檢查專案清單,再決定是否重試。', - restoreUnconfirmedTitle: '無法確認專案還原結果', - restoreUnconfirmed: '尚未請求切換工作階段的工作區。請先檢查專案是否已還原,再決定是否重試。', + registrationUnconfirmed: '尚未發起目前任務的工作區切換。請先檢查專案清單,再決定是否重試。', + restoreUnconfirmedTitle: '無法確認專案恢復結果', + restoreUnconfirmed: '尚未發起目前任務的工作區切換。請先檢查專案是否已恢復,再決定是否重試。', moveUnconfirmedTitle: '無法確認工作區切換結果', - moveUnconfirmed: '請先檢查此工作階段目前的工作區,再決定是否重試;不會自動再次提交切換。', + moveUnconfirmed: '請先檢查目前任務使用的工作區,再決定是否重試;不會自動再次提交切換。', refreshFailedTitle: '專案清單重新整理失敗', - refreshFailed: '重新整理失敗不會撤銷已完成的專案還原或工作區切換。請重新整理專案清單。', + refreshFailed: '重新整理失敗不會撤銷已完成的專案恢復或工作區切換。請重新整理專案清單。', }, en: { - confirmDescription: 'This directory belongs to an archived project. Restore the original project and use it as this session’s workspace? This will not create a session or send a message.', - confirmLabel: 'Restore and use for this session', - projectUnavailable: 'The project directory is unavailable. The session workspace was not changed. Relocate the directory or choose another available project.', + confirmDescription: 'This directory belongs to an archived project. Restore the original project and switch this task’s workspace to its directory? This will not create a new task or automatically send a message.', + confirmLabel: 'Restore and use for this task', + projectUnavailable: 'The project directory is unavailable. This task’s workspace was not changed. Select the project directory again or choose another available project.', restoredPrefix: 'The project was restored and has not been archived again.', registrationUnconfirmedTitle: 'Could not confirm project registration', - registrationUnconfirmed: 'No workspace move was requested. Check the project list before deciding to retry.', + registrationUnconfirmed: 'The request to switch this task’s workspace has not been sent. Check the project list before deciding to retry.', restoreUnconfirmedTitle: 'Could not confirm project restoration', - restoreUnconfirmed: 'No workspace move was requested. Check whether the project was restored before deciding to retry.', - moveUnconfirmedTitle: 'Could not confirm workspace move', - moveUnconfirmed: 'Check this session’s current workspace before deciding to retry. The move will not be submitted again automatically.', - refreshFailedTitle: 'Could not refresh projects', - refreshFailed: 'A refresh failure does not undo a completed project restoration or workspace move. Refresh the project list.', + restoreUnconfirmed: 'The request to switch this task’s workspace has not been sent. Check whether the project was restored before deciding to retry.', + moveUnconfirmedTitle: 'Could not confirm workspace switch', + moveUnconfirmed: 'Check this task’s current workspace before deciding to retry. The switch will not be retried automatically.', + refreshFailedTitle: 'Could not refresh the project list', + refreshFailed: 'A refresh failure does not undo a completed project restoration or workspace switch. Refresh the project list.', }, } satisfies UiCatalog; diff --git a/apps/desktop/src/renderer/features/task-entry/ui/project-registration-boundary.tsx b/apps/desktop/src/renderer/features/task-entry/ui/project-registration-boundary.tsx index 780025cc181..0a3b6098008 100644 --- a/apps/desktop/src/renderer/features/task-entry/ui/project-registration-boundary.tsx +++ b/apps/desktop/src/renderer/features/task-entry/ui/project-registration-boundary.tsx @@ -17,78 +17,15 @@ * under the License. */ -import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'; -import { useToast, useUiLocale } from '@maka/ui'; -import { getShellCopy } from '../../../locales/shell-copy.js'; -import type { TaskEntryHostRef, TaskEntryProjectMutationResult } from '../ports.js'; -import { useTaskEntryServices } from '../services-context.js'; -import { resolveProjectRegistration } from '../controller/resolve-project-registration.js'; +import type { ReactNode } from 'react'; +import type { TaskEntryHostRef } from '../ports.js'; +import { useProjectRegistration, type ProjectRegistration } from '../controller/use-project-registration.js'; -export interface ProjectRegistration { - readonly pending: boolean; - register( - operation: () => Promise, - isCurrent?: () => boolean, - ): Promise; -} - -/** Owns only the single-flight registration/archived-project recovery transaction. */ +/** Adapts registration to legacy callers without adding hooks to their lifecycle. */ export function ProjectRegistrationBoundary(props: { host?: TaskEntryHostRef; children(registration: ProjectRegistration): ReactNode; }) { - const { catalog } = useTaskEntryServices(); - const toast = useToast(); - const locale = useUiLocale(); - const copy = getShellCopy(locale).projectActions; - const [pending, setPending] = useState(false); - const generation = useRef(0); - const inFlight = useRef(false); - const mounted = useRef(false); - - useLayoutEffect(() => { - mounted.current = true; - generation.current += 1; - inFlight.current = false; - setPending(false); - return () => { - mounted.current = false; - generation.current += 1; - }; - }, [props.host?.profileId, props.host?.hostId, catalog, locale]); - - async function register( - operation: () => Promise, - isCurrent: () => boolean = () => true, - ): Promise { - const host = props.host; - if (!host || !mounted.current || inFlight.current || !isCurrent()) return; - const sequence = generation.current; - const current = () => mounted.current && generation.current === sequence && isCurrent(); - inFlight.current = true; - setPending(true); - try { - return await resolveProjectRegistration({ - register: operation, - confirm: (onConfirm) => toast.confirm({ - onConfirm, - title: copy.archivedProjectTitle, - description: copy.archivedProjectDescription, - confirmLabel: copy.archivedProjectRestore, - cancelLabel: copy.archivedProjectCancel, - }), - restore: (projectId) => catalog.restoreProject(host, projectId), - isCurrent: current, - }); - } catch (cause) { - if (current()) throw cause; - } finally { - if (mounted.current && generation.current === sequence) { - inFlight.current = false; - setPending(false); - } - } - } - - return props.children({ pending, register }); + const registration = useProjectRegistration(props.host); + return props.children(registration); } diff --git a/apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx b/apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx index 6e8ec6e0c06..4db37a4cd90 100644 --- a/apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx +++ b/apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx @@ -31,6 +31,7 @@ export interface TaskEntryHostModel { acceptRegisteredProject( project: ProjectRecord, host: TaskEntryHostRef, + restored?: boolean, ): Promise; } @@ -45,8 +46,8 @@ export function TaskEntryHostView({ model }: { model: TaskEntryHostModel }) { host={model.directoryHost} returnFocusTo={model.directoryOpener} onClose={model.closeDirectoryPicker} - onRegistered={(project, host) => { - void model.acceptRegisteredProject(project, host); + onRegistered={(project, host, restored) => { + void model.acceptRegisteredProject(project, host, restored); }} /> {model.newProjectDialog ? ( diff --git a/apps/desktop/src/renderer/locales/settings-projects-copy.ts b/apps/desktop/src/renderer/locales/settings-projects-copy.ts index 9f6921582ea..28520bceb2d 100644 --- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts @@ -284,10 +284,6 @@ export type SettingsProjectsCopy = { sectionHelp: string; projectsTitle: string; addProject: string; - archivedProjectTitle: string; - archivedProjectDescription: string; - archivedProjectRestore: string; - archivedProjectCancel: string; defaultBadge: string; setDefault: string; setDefaultTitle: string; @@ -647,10 +643,6 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { sectionHelp: '新任务默认打开此项目;未设置时沿用上次使用的项目。任何任务都能在输入框旁临时切换。', projectsTitle: '项目', addProject: '新建项目', - archivedProjectTitle: '项目已归档', - archivedProjectDescription: '该项目已归档,是否需要恢复?', - archivedProjectRestore: '恢复', - archivedProjectCancel: '取消', defaultBadge: '默认', setDefault: '设为默认', setDefaultTitle: '新任务默认打开这个项目', @@ -990,10 +982,6 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { sectionHelp: '新任務預設開啟此專案;未設定時沿用上次使用的專案。任何任務都能在輸入框旁臨時切換。', projectsTitle: '專案', addProject: '新增專案', - archivedProjectTitle: '專案已歸檔', - archivedProjectDescription: '該專案已歸檔,是否需要恢復?', - archivedProjectRestore: '恢復', - archivedProjectCancel: '取消', defaultBadge: '預設', setDefault: '設為預設', setDefaultTitle: '新任務預設開啟這個專案', @@ -1351,10 +1339,6 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { 'New tasks open in the default project; without one, they reuse the project you last used. You can switch any task to a different project next to the input box.', projectsTitle: 'Projects', addProject: 'New project', - archivedProjectTitle: 'Project archived', - archivedProjectDescription: 'This project is archived. Restore it?', - archivedProjectRestore: 'Restore', - archivedProjectCancel: 'Cancel', defaultBadge: 'Default', setDefault: 'Set as default', setDefaultTitle: 'Open new tasks in this project', diff --git a/apps/desktop/src/renderer/remote-project-directory-dialog.tsx b/apps/desktop/src/renderer/remote-project-directory-dialog.tsx index 8c157b8bf1e..40b051072c8 100644 --- a/apps/desktop/src/renderer/remote-project-directory-dialog.tsx +++ b/apps/desktop/src/renderer/remote-project-directory-dialog.tsx @@ -51,7 +51,7 @@ export function RemoteProjectDirectoryDialog(props: { host?: DirectoryHost; returnFocusTo?: HTMLElement | null; onClose(): void; - onRegistered(project: ProjectRecord, host: DesktopRuntimeHostRef): void; + onRegistered(project: ProjectRecord, host: DesktopRuntimeHostRef, restored: boolean): void; }) { const locale = useUiLocale(); const copy = getShellCopy(locale).projectActions; @@ -175,7 +175,7 @@ export function RemoteProjectDirectoryDialog(props: { : { ok: false, reason: 'archived', projectId: project.id }; }, () => sequence === undefined || request.current === sequence); if (request.current !== sequence || !result?.ok) return; - props.onRegistered(result.project, host); + props.onRegistered(result.project, host, result.restored === true); } catch (cause) { if (request.current !== sequence) return; reportUnexpectedError('project-directory:register', cause);