Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
603 changes: 603 additions & 0 deletions apps/desktop/src/main/__tests__/project-add-restore.test.ts

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions apps/desktop/src/main/__tests__/project-management-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
396 changes: 393 additions & 3 deletions apps/desktop/src/main/__tests__/task-entry-controller.test.ts

Large diffs are not rendered by default.

74 changes: 69 additions & 5 deletions apps/desktop/src/main/__tests__/task-entry-provider-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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',
}
Expand Down Expand Up @@ -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 });
},
});
}
Expand Down Expand Up @@ -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<Awaited<ReturnType<TaskEntryServices['catalog']['addProject']>>>();
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<Awaited<ReturnType<TaskEntryServices['sessions']['relocateWorkspace']>>>();
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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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;
Expand All @@ -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[]) => {
Expand All @@ -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 },
Expand All @@ -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'],
Expand Down
16 changes: 10 additions & 6 deletions apps/desktop/src/main/__tests__/workbar-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 })),
),
),
),
}),
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src/main/project-management-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CurrentProjectSelection>;
Expand Down Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion apps/desktop/src/preload/bridge-contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1013,6 +1013,11 @@ export interface MakaBridge {
getCatalog(): Promise<DesktopNewTaskCatalog>;
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<
Expand Down Expand Up @@ -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<readonly DesktopProjectDirectoryRoot[]>;
listDirectory(
Expand Down
15 changes: 13 additions & 2 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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);
},
Expand Down
39 changes: 39 additions & 0 deletions apps/desktop/src/renderer/features/task-entry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading