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
4 changes: 2 additions & 2 deletions apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@
"@maka/ui": 1
},
"importSpecifiers": 39,
"nonTriviaTokens": 4278
"nonTriviaTokens": 4267
},
"src/renderer/app-shell-chrome-actions.tsx": {
"importDeclarations": 5,
Expand Down Expand Up @@ -981,7 +981,7 @@
"react": 1
},
"importSpecifiers": 184,
"nonTriviaTokens": 15692
"nonTriviaTokens": 15688
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 3,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -295,14 +295,52 @@ describe('composer first-send cleanup', () => {
});

try {
assert.equal(await createAppShellChatActions(createActionsDeps()).send('hello'), true);
const actions = createAppShellChatActions(createActionsDeps());
let resolved = 0;
assert.equal(
await actions.send('hello', undefined, {
onSessionResolved: () => {
resolved += 1;
},
}),
true,
);
assert.equal(resolved, 1);
} finally {
restoreWindow();
}

assert.deepEqual(removed, []);
});

it('does not report a resolved session when the first send outcome is unknown', async () => {
let resolved = 0;
const restoreWindow = installWindow({
newTasks: { create: async () => ({ id: 'session-1' }) },
sessions: {
// `outcome_unknown`: the Host may have admitted the Message, so the
// Session is kept and the send counts as landed — but nothing proves
// the outcome, so it must not look like a resolved Session. The Work
// Board only links a task to a Session whose first send projected.
submitMessage: async () => ({ ok: false as const, reason: 'outcome_unknown' as const }),
},
});

try {
const actions = createAppShellChatActions(createActionsDeps());
const result = await actions.send('hello', undefined, {
onSessionResolved: () => {
resolved += 1;
},
});
assert.equal(result, true);
} finally {
restoreWindow();
}

assert.equal(resolved, 0);
});

it('projects the first message before activation while waiting to submit until observation', async () => {
const observation = deferred<void>();
const order: string[] = [];
Expand Down
82 changes: 78 additions & 4 deletions apps/desktop/src/main/__tests__/work-board-ipc-main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ describe('Work Board IPC', () => {
ipcMain: ipc as unknown as Pick<IpcMain, 'handle'>,
workspaceRoot: root,
mainWindowController: window,
validateLinkedSession: async () => true,
});
try {
const created = await ipc.invoke<WorkBoardIpcResult<{ id: string; revision: number }>>(
Expand Down Expand Up @@ -128,6 +129,7 @@ describe('Work Board IPC', () => {
'workBoard:archive',
'workBoard:unarchive',
'workBoard:remove',
'workBoard:linkSession',
]);
} finally {
registration.close();
Expand All @@ -143,11 +145,17 @@ describe('Work Board IPC', () => {
ipcMain: ipc as unknown as Pick<IpcMain, 'handle'>,
workspaceRoot: root,
mainWindowController: window,
validateLinkedSession: async () => true,
});
try {
const created = await ipc.invoke<WorkBoardIpcResult<{ id: string; revision: number }>>(
'workBoard:create',
itemInput(),
{
scope: { kind: 'project', projectId: 'p1' },
title: 'Review auth',
creator: { kind: 'user' },
provenance: { kind: 'manual' },
},
);
assert.ok(created.ok);
const id = created.ok ? created.value.id : '';
Expand All @@ -156,7 +164,15 @@ describe('Work Board IPC', () => {
WorkBoardIpcResult<{ title: string; revision: number; state: string }>
>('workBoard:update', id, { title: 'Review auth v2' });
assert.ok(renamed.ok);
assert.equal(renamed.ok && renamed.value.revision, 2);
assert.equal(renamed.ok && renamed.value.revision, 2);

const linked = await ipc.invoke<WorkBoardIpcResult<{ linkedSessions: unknown[] }>>(
'workBoard:linkSession',
id,
{ profileId: 'profile-1', hostId: 'host-1', sessionId: 'session-1', linkedAt: 103 },
);
assert.equal(linked.ok, true);
assert.equal(linked.ok && linked.value.linkedSessions.length, 1);

const staleRename = await ipc.invoke<WorkBoardIpcResult<unknown>>(
'workBoard:update',
Expand Down Expand Up @@ -214,11 +230,69 @@ describe('Work Board IPC', () => {
assert.ok(page.ok);
assert.equal(page.ok && page.value.items.length, 0);

// create, update, archive, unarchive, archive, remove = 6 mutations
// create, update, link, archive, unarchive, archive, remove = 7 mutations
const changed = window.events.filter(
(event) => event.channel === 'workBoard:changed',
);
assert.equal(changed.length, 6);
assert.equal(changed.length, 7);
} finally {
registration.close();
}
});
});

test('rejects a linked Session that the Host validator cannot prove', async () => {
await withTempRoot(async (root) => {
const ipc = createFakeIpcMain();
const window = createFakeWindowController();
const registration = registerWorkBoardIpc({
ipcMain: ipc as unknown as Pick<IpcMain, 'handle'>,
workspaceRoot: root,
mainWindowController: window,
validateLinkedSession: async () => false,
});
try {
const created = await ipc.invoke<WorkBoardIpcResult<{ id: string }>>(
'workBoard:create',
itemInput(),
);
assert.ok(created.ok);
const linked = await ipc.invoke<WorkBoardIpcResult<unknown>>(
'workBoard:linkSession',
created.ok ? created.value.id : '',
{ profileId: 'profile-1', hostId: 'host-1', sessionId: 'missing', linkedAt: 1 },
);
assert.equal(linked.ok, false);
if (!linked.ok) assert.equal(linked.code, 'invalid_input');
} finally {
registration.close();
}
});
});

test('rejects linking a Session to an Inbox item even when the Host validates', async () => {
await withTempRoot(async (root) => {
const ipc = createFakeIpcMain();
const window = createFakeWindowController();
const registration = registerWorkBoardIpc({
ipcMain: ipc as unknown as Pick<IpcMain, 'handle'>,
workspaceRoot: root,
mainWindowController: window,
validateLinkedSession: async () => true,
});
try {
const created = await ipc.invoke<WorkBoardIpcResult<{ id: string }>>(
'workBoard:create',
itemInput(),
);
assert.ok(created.ok);
const linked = await ipc.invoke<WorkBoardIpcResult<unknown>>(
'workBoard:linkSession',
created.ok ? created.value.id : '',
{ profileId: 'profile-1', hostId: 'host-1', sessionId: 'session-1', linkedAt: 1 },
);
assert.equal(linked.ok, false);
if (!linked.ok) assert.equal(linked.code, 'invalid_input');
} finally {
registration.close();
}
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/main/__tests__/work-board-panel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,13 @@ test('prevents a second Work Board create while the first request is pending', a
createCalls += 1;
return createResult.promise;
});
const input = harness.container.querySelector('input');
const input = harness.container.querySelector('textarea');
assert.ok(input);
input.value = 'Later';
const propsKey = Object.keys(input).find((key) => key.startsWith('__reactProps$'));
assert.ok(propsKey, 'missing React props on input');
const props = (input as unknown as Record<string, unknown>)[propsKey] as {
onChange?: (event: { target: HTMLInputElement; defaultPrevented: boolean }) => void;
onChange?: (event: { target: HTMLTextAreaElement; defaultPrevented: boolean }) => void;
};
assert.ok(props.onChange, 'missing React change handler');
await act(async () => {
Expand Down
109 changes: 109 additions & 0 deletions apps/desktop/src/main/__tests__/work-board-target.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
* 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 { describe, test } from 'node:test';
import { resolveWorkBoardStartTarget } from '../../renderer/features/task-entry/testing.js';
import type { TaskEntryCatalog } from '../../renderer/features/task-entry/testing.js';
import type { ProjectRecord } from '@maka/core/project';
import type { WorkBoardItem } from '@maka/core/work-board';

const item = (scope: WorkBoardItem['scope']): WorkBoardItem => ({
schemaVersion: 1,
id: 'item-1',
revision: 1,
scope,
title: 'Review auth',
state: 'todo',
archived: false,
creator: { kind: 'user' },
provenance: { kind: 'manual' },
linkedSessions: [],
createdAt: 1,
updatedAt: 1,
});

const catalog = (projects: readonly ProjectRecord[]): TaskEntryCatalog => ({
defaultProfileId: 'profile-1',
hosts: [{
profile: { id: 'profile-1', name: 'Local', kind: 'local' },
hostId: 'host-1',
readiness: 'ready',
state: 'available',
projects,
capabilities: { chooseClientDirectory: false, chooseHostDirectory: false, selectNoProject: true },
selectedProjectId: null,
chatDefaults: { permissionMode: 'ask', thinkingLevel: 'off' },
}],
});

describe('Work Board Start task target resolution', () => {
test('resolves an available project alias to a canonical Host target', () => {
const result = resolveWorkBoardStartTarget(
item({ kind: 'project', projectId: 'old-project-id' }),
catalog([{ id: 'canonical-project', aliases: ['old-project-id'], name: 'Project', locations: [], available: true }]),
);
assert.equal(result.ok, true);
if (result.ok) assert.deepEqual(result.target, { profileId: 'profile-1', hostId: 'host-1', projectId: 'canonical-project' });
});

test('rejects Inbox and unavailable projects', () => {
const inbox = resolveWorkBoardStartTarget(item({ kind: 'inbox' }), catalog([]));
const missing = resolveWorkBoardStartTarget(item({ kind: 'project', projectId: 'missing' }), catalog([]));
assert.equal(inbox.ok ? 'unexpected' : inbox.reason, 'inbox');
assert.equal(missing.ok ? 'unexpected' : missing.reason, 'unavailable');
});

test('rejects archived and ambiguous projects', () => {
const archived = resolveWorkBoardStartTarget(
item({ kind: 'project', projectId: 'old-project-id' }),
catalog([{ id: 'canonical-project', aliases: ['old-project-id'], name: 'Project', locations: [], available: true, archivedAt: 10 }]),
);
assert.equal(archived.ok ? 'unexpected' : archived.reason, 'unavailable');

const shared = { id: 'p1', aliases: ['shared-id'], name: 'Project', locations: [], available: true };
const multiHost: TaskEntryCatalog = {
defaultProfileId: 'profile-1',
hosts: [
{
profile: { id: 'profile-1', name: 'Local', kind: 'local' },
hostId: 'host-1',
readiness: 'ready',
state: 'available',
projects: [shared],
capabilities: { chooseClientDirectory: false, chooseHostDirectory: false, selectNoProject: true },
selectedProjectId: null,
chatDefaults: { permissionMode: 'ask', thinkingLevel: 'off' },
},
{
profile: { id: 'profile-2', name: 'Remote', kind: 'remote' },
hostId: 'host-2',
readiness: 'ready',
state: 'available',
projects: [{ ...shared, id: 'p2' }],
capabilities: { chooseClientDirectory: false, chooseHostDirectory: false, selectNoProject: true },
selectedProjectId: null,
chatDefaults: { permissionMode: 'ask', thinkingLevel: 'off' },
},
],
};
const ambiguous = resolveWorkBoardStartTarget(item({ kind: 'project', projectId: 'shared-id' }), multiHost);
assert.equal(ambiguous.ok ? 'unexpected' : ambiguous.reason, 'ambiguous');
});
});
27 changes: 21 additions & 6 deletions apps/desktop/src/main/__tests__/workbar-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { afterEach, describe, it } from 'node:test';
import { act, createElement, StrictMode } from 'react';
import type { ShellRunUpdate } from '@maka/core/events';
import type { SessionSummary } from '@maka/core/session';
import { LocaleProvider } from '@maka/ui';
import { LocaleProvider, type ToastApi } from '@maka/ui';
import { cleanupFakeDom, installReactRenderer } from './fake-dom.js';
import {
createFakeWorkbarServices,
Expand Down Expand Up @@ -109,9 +109,24 @@ function controller(): WorkbarController {
return latestController;
}

function createFakeToastApi(errors: string[] = []): ToastApi {
return {
toast: () => '',
success: () => '',
error: (title, description) => {
errors.push(description ? `${title}: ${description}` : title);
return '';
},
info: () => '',
warning: () => '',
confirm: async () => false,
dismiss: () => {},
};
}

function input(
activeSession: SessionSummary | undefined,
errors: string[] = [],
toastApi: ToastApi = createFakeToastApi(),
): UseWorkbarControllerInput {
return {
available: true,
Expand All @@ -121,7 +136,7 @@ function input(
authoritativeSessionIds: new Set(activeSession ? [activeSession.id] : []),
shellObscured: false,
modelChoices: [],
reportError: (title, description) => errors.push(`${title}: ${description}`),
toastApi,
};
}

Expand Down Expand Up @@ -441,18 +456,18 @@ describe('useWorkbarController', () => {
});

await act(async () =>
renderController(root, services, input(session('a'), currentErrors)),
renderController(root, services, input(session('a'), createFakeToastApi(currentErrors))),
);
await act(async () => controller().commands.openTool('terminal'));
await act(async () => currentStart.reject(new Error('current failure')));
assert.equal(currentErrors.length, 1);

await act(async () =>
renderController(root, services, input(session('a'), staleErrors)),
renderController(root, services, input(session('a'), createFakeToastApi(staleErrors))),
);
await act(async () => controller().commands.openTool('terminal'));
await act(async () =>
renderController(root, services, input(session('b'), staleErrors)),
renderController(root, services, input(session('b'), createFakeToastApi(staleErrors))),
);
await act(async () => staleStart.reject(new Error('stale failure')));
assert.deepEqual(staleErrors, []);
Expand Down
Loading