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
4 changes: 2 additions & 2 deletions apps/desktop/e2e-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@
"electron": "the same WebContentsView and unsent draft survive reparenting between native main and floating windows, shared appearance changes and the explicit return-from-picture-in-picture button"
},
"workhub-return-rail.spec.ts": {
"tests": 2,
"electron": "WorkHub lives in a separate native WebContentsView: compare its scrollport with the main Session after Host-backed sends, then open that Session and return through the shell while retaining the same WorkHub draft"
"tests": 3,
"electron": "WorkHub lives in a separate native WebContentsView: compare its scrollport with the main Session after Host-backed sends, then open that Session and return through the shell while retaining the same WorkHub draft; a Host-backed pending question survives renderer reload and native detach/dock with its selection and focus"
},
"zh-tw-locale.spec.ts": {
"tests": 1,
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/e2e/transcript-scroll-cost.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,9 @@ async function settled(page: Page): Promise<void> {

async function moveToTail(page: Page): Promise<void> {
await settled(page);
await page.locator(TURN).last().scrollIntoViewIfNeeded();
// The bounded transcript can replace its last Turn between locator
// resolution and scrolling. The scroll container owns this movement.
await page.locator(SCROLLER).evaluate((element) => { element.scrollTop = element.scrollHeight; });
await page.evaluate(() => new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
));
Expand Down
11 changes: 10 additions & 1 deletion apps/desktop/e2e/workhub-layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,16 @@ test('WorkHub uses its coordination model and shared attachment composer', async
await expect.poll(nativeWorkHubVisible).toBe(true);
await expect(workhub.locator(COMPOSER_INPUT)).toHaveText(draftBeforeOverlays);
await expect(page.locator('.workHubDockBackdrop')).toHaveCount(0);
await anchors.locator('.workhub-navigation-item').first().click();
const workRail = anchors.locator('.workhub-navigation-item').first();
await workRail.click();
await expect(page.locator('.workHubDock')).toBeVisible();
await expect(workhub.getByRole('region', { name: '筛选此 Work 的对话' })).toHaveCount(0);
await workRail.click();
await expect(workhub.getByRole('region', { name: '筛选此 Work 的对话' })).toBeVisible();
await workRail.click();
await expect(workhub.getByRole('region', { name: '筛选此 Work 的对话' })).toHaveCount(0);
await expect(workhub.locator(COMPOSER_INPUT)).toHaveText(draftBeforeOverlays);
await page.getByRole('button').filter({ has: page.getByText('WorkHub navigation regression', { exact: true }) }).click();
await expect(page.locator('.workHubDock')).toBeHidden();
await page.getByRole('button', { name: 'WorkHub', exact: true }).click();
await expect(page.locator('.workHubDock')).toBeVisible();
Expand Down
31 changes: 31 additions & 0 deletions apps/desktop/e2e/workhub-return-rail.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
* under the License.
*/

import { FAKE_ASK_USER_QUESTION_PROMPT } from '@maka/runtime/test-only/fake-backend';
import type { Page } from '@playwright/test';
import { awaitSendReady, COMPOSER_INPUT, expect, test, getWorkHubPage } from './fixtures';

Expand Down Expand Up @@ -90,7 +91,12 @@ test('Session keeps a return to WorkHub control when the sidebar is collapsed',
await page.evaluate(() => window.maka.settings.updateClient({ workHub: { enabled: true } }));
const workhub = await getWorkHubPage(app);
await workhub.locator(COMPOSER_INPUT).fill('Keep my WorkHub draft');
const sessionName = await workhub.locator('.workhub-navigation-label').first().innerText();
await workhub.locator('.workhub-navigation-item').first().click();
await expect(page.locator('.workHubDock')).toBeVisible();
const expand = page.getByRole('button', { name: '展开侧边栏', exact: true });
if (await expand.isVisible()) await expand.click();
await page.getByRole('button').filter({ has: page.getByText(sessionName, { exact: true }) }).click();
await expect(page.locator('.workHubDock')).toBeHidden();
const collapse = page.getByRole('button', { name: '收起侧边栏', exact: true });
if (await collapse.isVisible()) await collapse.click();
Expand Down Expand Up @@ -119,3 +125,28 @@ test('Session keeps a return to WorkHub control when the sidebar is collapsed',
await expect(page.locator('.workHubDock')).toBeVisible();
await expect(workhub.locator(COMPOSER_INPUT)).toHaveText('Keep my WorkHub draft');
});


test('a pending WorkHub question preserves docked placement, choices and focus across window transitions', async ({ sessionLocalWindow: { page, app } }) => {
await page.evaluate(() => window.maka.settings.updateClient({ workHub: { enabled: true } }));
const hub = await getWorkHubPage(app);
await hub.locator(COMPOSER_INPUT).fill(FAKE_ASK_USER_QUESTION_PROMPT);
await awaitSendReady(hub);
await hub.locator(COMPOSER_INPUT).press('Enter');
await expect(hub.getByRole('radio', { name: /公开测试/ })).toBeVisible();
await expect(hub.locator('.workHubLive')).toHaveAttribute('data-placement', 'docked');
// Reload rehydrates the pending question through Host interaction queries.
await hub.reload();
const choice = hub.getByRole('radio', { name: /公开测试/ });
await expect(choice).toBeVisible();
await expect(hub.locator('.workHubLive')).toHaveAttribute('data-placement', 'docked');
await choice.click();
await app.evaluate(async ({ app }) => { if (process.platform === 'darwin') await app.dock!.show(); });
await hub.evaluate(() => window.maka.workHubPresentation.detach());
await expect(hub.locator('.workHubLive')).toHaveAttribute('data-placement', 'floating');
await expect(choice).toBeChecked();
await expect.poll(() => hub.locator('.maka-choice-panel').evaluate((panel) => panel.contains(document.activeElement))).toBe(true);
await hub.evaluate(() => window.maka.workHubPresentation.dock());
await expect(hub.locator('.workHubLive')).toHaveAttribute('data-placement', 'docked');
await expect(choice).toBeChecked();
});
45 changes: 34 additions & 11 deletions apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,23 @@ import {
matchesWorkHubFilter,
MAX_WORKHUB_ANCHORS,
WorkHubNavigationRail,
WorkHubResultCard,
workHubLinkedWork,
workHubTurnResultPreview,
} from "../../renderer/features/workhub/index.js";
import { ChatSurfaceLayout, LocaleProvider } from '@maka/ui';
import { WorkHubConversation, WorkHubDelegationStatus } from '../../renderer/features/workhub/testing.js';
import { getWorkHubRailCopy } from "../../renderer/locales/workhub-copy.js";
import type { ToolCallMessage, ToolResultMessage } from '@maka/core/session';

test('durable task results restore Host-scoped work links without treating failed or unrelated tools as delegations', () => {
const target = JSON.stringify(['host-a', 'task-a']);
const call: ToolCallMessage = { type: 'tool_call', id: 'task-call', turnId: 'turn', ts: 1, toolName: 'mcp__desktop_workhub__tasks', args: {} };
const result: ToolResultMessage = { type: 'tool_result', id: 'task-result', turnId: 'turn', ts: 2, toolUseId: call.id, isError: false, content: { kind: 'json', value: { disposition: 'create_new', targetSessionKey: target } } };
const expected = [{ id: result.id, coordinationTurnId: call.turnId, targetSessionId: target, targetSessionName: 'Renamed task' }];
const expected = [{ id: result.id, coordinationTurnId: call.turnId, targetSessionId: target, targetSessionName: 'Renamed task', workspaceName: undefined }];
assert.deepEqual(workHubLinkedWork([call, result], [{ id: target, name: 'Renamed task' }], 'Work'), expected);
for (const cwd of ['/projects/payments/', 'C:\\projects\\payments\\']) {
assert.deepEqual(workHubLinkedWork([call, result], [{ id: target, name: 'Renamed task', cwd }], 'Work'), [{ ...expected[0], workspaceName: 'payments' }]);
}
assert.deepEqual(workHubLinkedWork([call, { ...result, content: { kind: 'json', value: { content: [], structuredContent: { disposition: 'create_new', targetSessionKey: target } } } }], [{ id: target, name: 'Renamed task' }], 'Work'), expected);
assert.deepEqual(workHubLinkedWork([call, { ...result, content: { kind: 'text', text: JSON.stringify({ disposition: 'delegate_existing', targetSessionKey: target }) } }], [], 'Work'), [{ ...expected[0], targetSessionName: 'Work' }]);
assert.deepEqual(workHubLinkedWork([
Expand All @@ -50,9 +54,9 @@ test('durable task results restore Host-scoped work links without treating faile
], [], 'Work'), []);
});

test('a completed delegation returns its bounded result in the WorkHub conversation', () => {
test('a completed delegation renders only its status beside the prompt timestamp', () => {
const target = JSON.stringify(['host-a', 'task-a']);
const markup = renderToStaticMarkup(createElement(WorkHubResultCard, {
const markup = renderToStaticMarkup(createElement(WorkHubDelegationStatus, {
work: {
id: 'delegation-record',
coordinationTurnId: 'coordination-turn',
Expand All @@ -64,14 +68,10 @@ test('a completed delegation returns its bounded result in the WorkHub conversat
resultPreview: 'All release checks passed. The report is ready.',
},
locale: 'en',
highlighted: false,
onHighlight: () => undefined,
onOpenWork: () => undefined,
}));

assert.match(markup, /Completed/u);
assert.match(markup, /All release checks passed\. The report is ready\./u);
assert.match(markup, /Open result/u);
assert.doesNotMatch(markup, /All release checks|Open result/u);
});

test('delegated result previews select the exact Turn and stay character-bounded', () => {
Expand Down Expand Up @@ -170,7 +170,6 @@ test("rail copy distinguishes bounded anchors from all matching work", () => {
sessions: many,
delegatedSessionIds: [],
copy: getWorkHubRailCopy("en"),
onOpenSession: () => undefined,
}));

assert.match(markup, /8\/20 anchors · 20 total/u);
Expand All @@ -181,8 +180,32 @@ test("rail copy distinguishes bounded anchors from all matching work", () => {
test("focus display is derived from the selected Session ID, not delegation priority", () => {
const markup = renderToStaticMarkup(createElement(WorkHubNavigationRail, {
locale: "en", sessions, focusSessionId: "focus", delegatedSessionIds: ["delegated"],
copy: getWorkHubRailCopy("en"), onOpenSession: () => undefined,
copy: getWorkHubRailCopy("en"),
}));
assert.equal(markup.match(/aria-current="page"/gu)?.length, 1);
assert.equal(markup.match(/Focused · Running/gu)?.length, 1);
});


test('a shared coordination turn keeps every Work label without assigning one Work color to the whole turn', () => {
const markup = renderToStaticMarkup(createElement(LocaleProvider, { locale: 'en', children: null },
createElement(ChatSurfaceLayout, { composer: null, children: null }, createElement(WorkHubConversation, {
activeSession: { id: 'coordination', name: 'WorkHub', status: 'active', labels: [], isFlagged: false, isArchived: false, hasUnread: false, backend: 'ai-sdk', llmConnectionSlug: 'test', connectionLocked: false, model: 'test', permissionMode: 'ask' },
messages: [{ type: 'user', id: 'user', turnId: 'shared', text: 'Do both tasks', ts: 1 }],
scrollBehavior: 'auto', onNew: () => {}, onOpenWork: () => {},
workLinks: ['Alpha', 'Beta'].map((name) => ({ id: name, coordinationTurnId: 'shared', targetSessionId: name, targetSessionName: name, workspaceName: 'Workspace' })),
})),
));
assert.match(markup, /Workspace \/ Alpha/);
assert.match(markup, /Workspace \/ Beta/);
assert.doesNotMatch(markup, /data-turn-accent/);
});


test('WorkHub workspace display names handle Host paths independently of renderer platform', async () => {
const { workspaceNameFromCwd } = await import('../../renderer/features/workhub/testing.js');
assert.equal(workspaceNameFromCwd('/projects/maka/'), 'maka');
assert.equal(workspaceNameFromCwd('C:\\projects\\maka\\'), 'maka');
assert.equal(workspaceNameFromCwd(undefined), undefined);
assert.equal(workspaceNameFromCwd('/'), undefined);
});
69 changes: 68 additions & 1 deletion apps/desktop/src/main/__tests__/workhub-presentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@ test('control shows a passive card only after it is painted and preserves manual
assert.equal(h.main.visible, false, 'control does not reveal a hidden main window');
assert.equal(view.webContents.sent.some(([channel]) => channel.endsWith('focus-composer')), false);
await h.command(view.webContents, 'ready');
await h.command(view.webContents, 'show-conversation');
await h.command(view.webContents, 'show-conversation', request);
assert.equal(h.controller.getSnapshot().progressRequest, undefined);
assert.equal(floating.bounds.width, 520);
assert.equal(floating.bounds.height, 720);
Expand All @@ -477,6 +477,34 @@ test('control shows a passive card only after it is painted and preserves manual
h.controller.dispose();
});

test('interaction content upgrades native progress in either paint order without activation or reopening dismissal', async () => {
for (const paintFirst of [false, true]) {
const h = await harness();
await h.command(h.main.webContents, 'host', { visible: true, rect: { x: 0, y: 0, width: 1000, height: 800 } });
const view = h.views[0]!;
await h.controller.prepareControl('interaction-turn');
const request = h.controller.getSnapshot().progressRequest!;
const floating = h.windows[1]!;
if (paintFirst) await h.command(view.webContents, 'progress-ready', request);
const layout = { expanded: true, compactHeight: 250, interactionPending: true };
await h.command(view.webContents, 'conversation-layout', layout);
assert.equal(h.controller.getSnapshot().progressRequest, undefined);
assert.equal(floating.visible, true);
assert.equal(floating.resizable, true);
assert.equal(floating.bounds.width, 520);
assert.equal(floating.bounds.height, 720);
assert.equal(floating.focused, 0);
assert.equal(h.main.focused, 0);
await h.command(view.webContents, 'progress-ready', request);
assert.equal(floating.bounds.height, 720, 'stale paint cannot restore progress');
await h.command(view.webContents, 'hide');
await h.command(view.webContents, 'conversation-layout', layout);
await h.controller.prepareControl('interaction-turn');
assert.equal(floating.visible, false, 'content cannot override dismissal of this Turn');
h.controller.dispose();
}
});

test('control preparation refuses disabled presentation before opening and rechecks a pending open', async () => {
const h = await harness();
h.setEnabled(false);
Expand Down Expand Up @@ -914,3 +942,42 @@ test('the progress card stays hidden in a hidden run and inactive everywhere els
h.controller.dispose();
}
});


test('revealing an interaction cannot detach a docked WorkHub without a current progress request', async () => {
const h = await harness();
await h.command(h.main.webContents, 'host', { visible: true, rect: { x: 0, y: 0, width: 1000, height: 800 } });
const view = h.views[0]!;
for (const request of [undefined, 1, NaN]) {
if (request === undefined || Number.isNaN(request)) await assert.rejects(h.command(view.webContents, 'show-conversation', request), /Invalid progress request/);
else await h.command(view.webContents, 'show-conversation', request);
assert.equal(h.controller.getSnapshot().placement, 'docked');
assert.equal(h.controller.getSnapshot().floatingVisible, false);
}
});


test('expanding progress before its first paint reveals the conversation without taking focus', async () => {
for (const mode of ['active', 'hidden'] as const) {
const h = await harness(false, 60, mode);
await h.controller.prepareControl('turn-early');
const view = h.views[0]!;
const floating = h.windows[1]!;
floating.isFocused = () => false;
const request = h.controller.getSnapshot().progressRequest!;
await h.command(view.webContents, 'ready');
assert.equal(floating.visible, false);
const focusMessages = view.webContents.sent.filter(([channel]) => channel.endsWith('focus-composer')).length;
await h.command(view.webContents, 'show-conversation', request);
assert.equal(floating.visible, mode !== 'hidden', 'expansion must finish the native reveal without waiting for the unmounted progress card');
assert.equal(h.controller.getSnapshot().progressRequest, undefined);
assert.equal(floating.focused, 0);
assert.equal(view.webContents.sent.filter(([channel]) => channel.endsWith('focus-composer')).length, focusMessages);
assert.equal(view.webContents.backgroundThrottling, true);
await h.command(view.webContents, 'hide');
await h.command(view.webContents, 'progress-ready', request);
await h.command(view.webContents, 'show-conversation', request);
assert.equal(floating.visible, false, 'late acknowledgements and stale expansions cannot reopen a dismissed window');
h.controller.dispose();
}
});
Loading