Skip to content
Open
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
29 changes: 29 additions & 0 deletions apps/desktop/src/main/__tests__/task-entry-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -739,4 +739,33 @@ describe('useTaskEntryController', () => {
profileId: 'local',
}]);
});

it('explains that a running Session must settle before workspace recovery', async () => {
const { root } = installReactRenderer();
const errors: unknown[] = [];
const services = createFakeTaskEntryServices({
catalog: {
...createFakeTaskEntryServices().catalog,
getCatalog: async () => catalog(),
},
sessions: {
relocateWorkspace: async () => ({ ok: false, reason: 'session_busy' }),
},
});

await act(async () => renderController(root, services, errors));
await act(async () => {
await controller().commands.relocateSessionWorkspace({
sessionId: 'session-1',
profileId: 'local',
projectId: 'project-a',
});
});

assert.deepEqual(errors, [{
title: 'Could not move task',
description: 'A task is running. Wait for it to finish before moving this one.',
profileId: 'local',
}]);
});
});
170 changes: 167 additions & 3 deletions apps/desktop/src/main/__tests__/task-entry-provider-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@

import { strict as assert } from 'node:assert';
import { afterEach, describe, it } from 'node:test';
import { act, createElement, Fragment } from 'react';
import { LocaleProvider, ToastProvider } from '@maka/ui';
import { act, createElement, Fragment, type ReactNode } from 'react';
import { LocaleProvider, ToastProvider, type WorkspacePickerModel } from '@maka/ui';
import { cleanupFakeDom, installReactRenderer } from './fake-dom.js';
import {
createFakeTaskEntryServices,
Expand All @@ -42,6 +42,9 @@ let latestTaskEntry: TaskEntryShellProjection | undefined;
let latestDirectoryHostId: string | undefined;
let latestProjectDialog: ReturnType<typeof useTaskEntryHostModel>['newProjectDialog'];
let latestWorkspaceGroupCount = 0;
let latestRecoveryPicker: WorkspacePickerModel | undefined;
let recoverySelectedProject: string | undefined;
let recoveryPickerOpenStates: boolean[] = [];

function project(id: string) {
return {
Expand Down Expand Up @@ -70,10 +73,31 @@ function remoteHost(): Extract<TaskEntryHost, { state: 'available' }> {
};
}

function localHost(projects: ReturnType<typeof project>[] = []): Extract<TaskEntryHost, { state: 'available' }> {
return {
profile: { id: 'local', name: 'Local', kind: 'local' },
hostId: 'host-local',
readiness: 'ready',
state: 'available',
projects,
capabilities: {
chooseClientDirectory: true,
chooseHostDirectory: false,
selectNoProject: true,
},
selectedProjectId: projects[0]?.id,
chatDefaults: { permissionMode: 'ask', thinkingLevel: 'high' },
};
}

function catalog(): TaskEntryCatalog {
return { defaultProfileId: 'remote', hosts: [remoteHost()] };
}

function localCatalog(projects: ReturnType<typeof project>[] = []): TaskEntryCatalog {
return { defaultProfileId: 'local', hosts: [localHost(projects)] };
}

function WorkspaceProbe() {
return createElement(TaskEntryWorkspacePickerConsumer, {
manageProjects() {},
Expand All @@ -93,6 +117,34 @@ function HostProbe() {
return null;
}

function RecoveryWorkspaceProbe({ local = false }: { local?: boolean }) {
return createElement(TaskEntryWorkspacePickerConsumer, {
manageProjects() {},
activeSession: local
? {
id: 'session-1',
profileId: 'local',
runtimeHostId: 'host-local',
projectId: null,
profileKind: 'local',
}
: {
id: 'session-1',
profileId: 'remote',
runtimeHostId: 'host-remote',
projectId: 'missing-project',
profileKind: 'remote',
},
children: (workspacePicker) => {
latestRecoveryPicker = workspacePicker;
if (workspacePicker.showForActiveSession) {
recoveryPickerOpenStates.push(workspacePicker.isMenuOpen === true);
}
return null;
},
});
}

function FrameProbe() {
frameRenders += 1;
return createElement(Fragment, null, createElement(WorkspaceProbe), createElement(HostProbe));
Expand All @@ -108,9 +160,28 @@ function ShellProbe() {
});
}

function RecoveryShellProbe() {
return createElement(TaskEntryRoot, {
children: (taskEntry) => {
latestTaskEntry = taskEntry;
return createElement(RecoveryWorkspaceProbe);
},
});
}

function LocalRecoveryShellProbe() {
return createElement(TaskEntryRoot, {
children: (taskEntry) => {
latestTaskEntry = taskEntry;
return createElement(RecoveryWorkspaceProbe, { local: true });
},
});
}

function renderProvider(
root: ReturnType<typeof installReactRenderer>['root'],
services: TaskEntryServices,
probe: ReactNode = createElement(ShellProbe),
) {
root.render(
createElement(LocaleProvider, {
Expand All @@ -121,7 +192,7 @@ function renderProvider(
createElement(
TaskEntryServicesProvider,
{ services },
createElement(ShellProbe),
probe,
),
),
}),
Expand All @@ -137,6 +208,9 @@ afterEach(() => {
latestDirectoryHostId = undefined;
latestProjectDialog = undefined;
latestWorkspaceGroupCount = 0;
latestRecoveryPicker = undefined;
recoverySelectedProject = undefined;
recoveryPickerOpenStates = [];
cleanupFakeDom();
});

Expand Down Expand Up @@ -220,4 +294,94 @@ describe('TaskEntryRoot render scope', () => {

await act(async () => root.unmount());
});

it('scopes active-session recovery to available projects on that Host', async () => {
const { root } = installReactRenderer();
const services = createFakeTaskEntryServices({
catalog: {
...createFakeTaskEntryServices().catalog,
getCatalog: async () => catalog(),
},
sessions: {
relocateWorkspace: async (_sessionId, projectId) => {
recoverySelectedProject = projectId;
return { ok: true };
},
},
});

await act(async () => {
root.render(
createElement(LocaleProvider, {
locale: 'en',
children: createElement(
ToastProvider,
null,
createElement(
TaskEntryServicesProvider,
{ services },
createElement(RecoveryShellProbe),
),
),
}),
);
});

await act(async () => {
latestTaskEntry?.commands.openSessionWorkspaceRecovery('session-1');
});

assert.equal(latestRecoveryPicker?.showForActiveSession, true);
assert.equal(latestRecoveryPicker?.isMenuOpen, true);
assert.deepEqual(recoveryPickerOpenStates.slice(-2), [false, true]);
assert.equal(latestRecoveryPicker?.groups.length, 1);
assert.deepEqual(
latestRecoveryPicker?.groups[0]?.projects.map(({ id }) => id),
['project-a'],
);
await act(async () => {
latestRecoveryPicker?.groups[0]?.onSelectProject?.('project-a');
await Promise.resolve();
});
assert.equal(recoverySelectedProject, 'project-a');

await act(async () => root.unmount());
});

it('adds a local Project before relocating the active Session', async () => {
const { root } = installReactRenderer();
const calls: string[] = [];
let reads = 0;
const services = createFakeTaskEntryServices({
catalog: {
...createFakeTaskEntryServices().catalog,
getCatalog: async () => {
reads += 1;
return reads === 1 ? localCatalog() : localCatalog([project('project-new')]);
},
addProject: async (host) => {
calls.push(`add:${host.profileId}:${host.hostId}`);
return { ok: true, project: project('project-new') };
},
},
sessions: {
relocateWorkspace: async (sessionId, projectId) => {
calls.push(`relocate:${sessionId}:${projectId}`);
return { ok: true };
},
},
});

await act(async () => renderProvider(root, services, createElement(LocalRecoveryShellProbe)));
await act(async () => latestTaskEntry?.commands.openSessionWorkspaceRecovery('session-1'));
assert.equal(typeof latestRecoveryPicker?.groups[0]?.onAdd, 'function');

await act(async () => {
latestRecoveryPicker?.groups[0]?.onAdd?.('Imported');
await new Promise((resolve) => setTimeout(resolve, 0));
});

assert.deepEqual(calls, ['add:local:host-local', 'relocate:session-1:project-new']);
await act(async () => root.unmount());
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,13 @@ describe('createDesktopTaskEntryServices', () => {
restore: async (...args: unknown[]) =>
calls.push({ name: 'restoreProject', args }),
},
} as unknown as Pick<MakaBridge, 'newTasks' | 'projects'>;
sessions: {
moveToProject: async (...args: unknown[]) => {
calls.push({ name: 'moveToProject', args });
return { ok: true, session: {} };
},
},
} as unknown as Pick<MakaBridge, 'newTasks' | 'projects' | 'sessions'>;
const services = createDesktopTaskEntryServices(bridge);
const host = { profileId: 'remote', hostId: 'host-1' };

Expand All @@ -74,6 +80,10 @@ describe('createDesktopTaskEntryServices', () => {
await services.catalog.renameProject(host, 'project-1', 'Renamed');
await services.catalog.archiveProject(host, 'project-1');
await services.catalog.restoreProject(host, 'project-1');
assert.deepEqual(
await services.sessions.relocateWorkspace('session-1', 'project-1'),
{ ok: true },
);
unsubscribe();

assert.deepEqual(calls, [
Expand All @@ -84,6 +94,10 @@ describe('createDesktopTaskEntryServices', () => {
{ name: 'renameProject', args: ['project-1', 'Renamed', host] },
{ name: 'archiveProject', args: ['project-1', host] },
{ name: 'restoreProject', args: ['project-1', host] },
{
name: 'moveToProject',
args: ['session-1', 'project-1'],
},
]);
assert.equal(changes, 1);
assert.equal(disposed, 1);
Expand Down
Loading