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 new file mode 100644 index 00000000000..1ef2b9b0920 --- /dev/null +++ b/apps/desktop/src/main/__tests__/project-add-restore.test.ts @@ -0,0 +1,603 @@ +/* + * 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, 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<{ + host?: DesktopRuntimeHostRef; + onClose(): void; + onRegistered(project: ProjectRecord, host: DesktopRuntimeHostRef, restored: boolean): void; + }>; + SettingsFixture: ComponentType<{ host?: DesktopRuntimeHostRef; verified?: boolean }>; + SessionRecoveryFixture: ComponentType<{ sessionId?: string }>; + TaskEntryServicesProvider: ComponentType<{ services: TaskEntryServices; children: ReactNode }>; + createFakeTaskEntryServices(): TaskEntryServices; +} +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, 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 { 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, + children: createElement(ProjectsSettingsPage, { + settings: { projects: {} }, runtimeHostStatus: 'ready', runtimeHostTargetVerified: verified, + 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, + 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, [role="menuitem"]')].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(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 () => { + 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 () => { + for (let index = 0; index < repetitions; index++) { + form.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true })); + } + }); + await flushFrames(); +} + +// 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 task'); + 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[][] = []; + const restorationFlags: boolean[] = []; + bridge({ + registerDirectory: async () => outcome === 'normal' ? restored : archived, + }); + 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, _host, wasRestored) { accepted.push(project); restorationFlags.push(wasRestored); }, + }), + ); + await render(); + await click('Add this folder'); + 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'); + assert.match(document.body.textContent, /Project archived/); + 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, [[host, 'p']]); + assert.deepEqual(accepted, [restored]); + assert.deepEqual(restorationFlags, [true]); + } 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' }; + }, + }); + harness.services.catalog.restoreProject = 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', 'normal', 'refresh-failure'] as const) { + test(`Settings successful Add: ${outcome}`, async () => { + const harness = installRenderer(); + let restores = 0; + let snapshots = 0; + bridge({ + 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); + 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'); + }); +} + +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 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: createElement(components.TaskEntryServicesProvider, { services, 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..f0954382fae 100644 --- a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts +++ b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts @@ -21,8 +21,12 @@ 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 { + createDesktopTaskEntryServices, + type DesktopTaskEntryBridge, +} from '../../renderer/platform/desktop/create-task-entry-services.js'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { createFakeTaskEntryServices, @@ -99,10 +103,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; onConfirm?(): Promise }): Promise; +}) { latestController = useTaskEntryController({ reportError: props.reportError, manageProjects() {}, + ...(props.confirm ? { confirm: props.confirm } : {}), }); return null; } @@ -116,26 +124,291 @@ function renderController( root: ReturnType['root'], 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, + createElement( TaskEntryServicesProvider, { services }, createElement(ControllerProbe, { reportError: (error: unknown) => errors.push(error), + confirm: confirm ? async (input) => { + const accepted = await confirm(input); + if (accepted) await input.onConfirm?.(); + return accepted; + } : undefined, }), ), + ), }), ); } +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 task'); + assert.match(input.description ?? '', /this task’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 the project list/); + 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(); @@ -194,6 +467,7 @@ describe('useTaskEntryController', () => { }, restoreProject: async (host) => { calls.push({ action: 'restore', ...host }); + return { ok: true, project: project('project-a') }; }, }, }); @@ -329,6 +603,92 @@ describe('useTaskEntryController', () => { assert.equal(controller().selectors.workspacePicker.pending, false); }); + 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; + 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-b', + }), + restoreProject: async () => { + restoreCalls += 1; + return { ok: true as const, project: project('project-b') }; + }, + }, + }); + + const errors: unknown[] = []; + const desktopServices = createDesktopTaskEntryServices({ + newTasks: services.catalog, + projects: {}, + } 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(); + }); + await act(async () => {}); + + assert.equal(restoreCalls, 1); + 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 () => { + 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<{ @@ -415,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__/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 656e899dedd..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 @@ -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,6 @@ 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 }), }, sessions: { moveToProject: async (...args: unknown[]) => { @@ -79,7 +85,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 +99,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/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/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..20385b932ea 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', @@ -3017,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 c11639973d7..26f68224c5a 100644 --- a/apps/desktop/src/renderer/features/task-entry/README.md +++ b/apps/desktop/src/renderer/features/task-entry/README.md @@ -66,6 +66,45 @@ 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 + +`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. The hook 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..fab07b939f9 --- /dev/null +++ b/apps/desktop/src/renderer/features/task-entry/controller/resolve-project-registration.ts @@ -0,0 +1,50 @@ +/* + * 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 { 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 { + 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.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-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 33bf613ccb4..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 @@ -31,8 +31,10 @@ import { type RuntimeHostProfileKind, } from '@maka/runtime-host/profile-kind'; import { + type ConfirmInput, getConversationCopy, type WorkspacePickerModel, + useToast, useUiLocale, } from '@maka/ui'; import { getShellCopy, localizedShellErrorMessage } from '../../../locales/shell-copy.js'; @@ -59,10 +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: ConfirmInput): Promise; } export interface TaskEntryControllerSelectors { @@ -80,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[]; @@ -99,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. */ @@ -170,12 +172,12 @@ 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; 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( @@ -184,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); @@ -321,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; @@ -406,34 +349,38 @@ 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), - profileId: host.profile.id, - }); - return; - } - if (!result.ok) return; + 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, + }), + restore: (projectId) => service.restoreProject( + { profileId: host.profile.id, hostId: host.hostId }, + projectId, + ), + isCurrent: () => true, + }); + if (!result?.ok) return; setSelectedProfileId(host.profile.id); setProjectSelections((current) => 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, input.confirm, locale, refreshAfterProjectMutation, reportError, service, toast]); const chooseProjectForProfile = useCallback(async (profileId: string): Promise => { let next: TaskEntryCatalog | undefined; @@ -468,6 +415,7 @@ export function useTaskEntryController( const acceptRegisteredProject = useCallback(async ( project: ProjectRecord, registeredHost: TaskEntryHostRef, + restored = false, ): Promise => { const host = directoryHost; if ( @@ -481,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 daacd288f06..79f3c0bad1c 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 './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 new file mode 100644 index 00000000000..a443be91ef9 --- /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 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: '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: '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; + +export function getSessionWorkspaceRecoveryCopy(locale: UiLocale): SessionWorkspaceRecoveryCopy { + return COPY_BY_LOCALE[locale]; +} diff --git a/apps/desktop/src/renderer/features/task-entry/ports.ts b/apps/desktop/src/renderer/features/task-entry/ports.ts index 6a311d0e1bc..c37d6c3eb73 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, @@ -129,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 def18db03fd..21fdef15d5f 100644 --- a/apps/desktop/src/renderer/features/task-entry/testing.ts +++ b/apps/desktop/src/renderer/features/task-entry/testing.ts @@ -54,10 +54,10 @@ 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, - restoreProject: async () => undefined, }, sessions: { relocateWorkspace: async () => ({ ok: false, reason: 'operation_unavailable' }), 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..0a3b6098008 --- /dev/null +++ b/apps/desktop/src/renderer/features/task-entry/ui/project-registration-boundary.tsx @@ -0,0 +1,31 @@ +/* + * 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 { ReactNode } from 'react'; +import type { TaskEntryHostRef } from '../ports.js'; +import { useProjectRegistration, type ProjectRegistration } from '../controller/use-project-registration.js'; + +/** Adapts registration to legacy callers without adding hooks to their lifecycle. */ +export function ProjectRegistrationBoundary(props: { + host?: TaskEntryHostRef; + children(registration: ProjectRegistration): ReactNode; +}) { + 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/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/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/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) { diff --git a/apps/desktop/src/renderer/remote-project-directory-dialog.tsx b/apps/desktop/src/renderer/remote-project-directory-dialog.tsx index 2209482e0f1..40b051072c8 100644 --- a/apps/desktop/src/renderer/remote-project-directory-dialog.tsx +++ b/apps/desktop/src/renderer/remote-project-directory-dialog.tsx @@ -26,6 +26,7 @@ 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 { 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 { @@ -50,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; @@ -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([]); @@ -155,19 +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 { - const project = await window.maka.projects.registerDirectory({ - rootId: root.id, - segments, - }, 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, result.restored === true); } catch (cause) { if (request.current !== sequence) return; reportUnexpectedError('project-directory:register', cause); @@ -196,6 +204,8 @@ export function RemoteProjectDirectoryDialog(props: { ? entries : entries.filter((entry) => !entry.name.startsWith('.')); return ( + + {(registration) => ( { @@ -325,7 +335,7 @@ export function RemoteProjectDirectoryDialog(props: { label={copy.remoteDirectorySelect} isDisabled={!root || loading || registering} isLoading={registering} - onClick={() => void register()} + onClick={() => void register(registration)} /> @@ -333,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 e84c670686a..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'; @@ -111,14 +112,17 @@ 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, registration: ProjectRegistration) { + if (!host || !props.runtimeHostTargetVerified) return; + await runRowAction('add', async () => { + 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); + } useEffect(() => { if (!host || !props.runtimeHostTargetVerified) { @@ -167,7 +171,7 @@ export function ProjectsSettingsPage(props: { async function runRowAction( key: string, - action: () => Promise, + action: () => Promise, failure: string, ) { if (!props.runtimeHostTargetVerified) return; @@ -175,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( @@ -244,6 +248,8 @@ export function ProjectsSettingsPage(props: { ); } return ( + + {(registration) => ( { if (props.runtimeHostTargetVerified) setProjectDialog('directory'); } @@ -513,7 +520,7 @@ export function ProjectsSettingsPage(props: { onClose={() => setProjectDialog(null)} onRegistered={() => { setProjectDialog(null); - void reload(); + void runRowAction('add', async () => {}, copy.actionFailed); }} /> {projectDialog === 'new' && props.runtimeHostTargetVerified ? ( @@ -522,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/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' }, 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)} />