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
Binary file added .github/assets/workhub-progress/card-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/assets/workhub-progress/card-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 15 additions & 1 deletion apps/desktop/e2e/workhub-layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,21 @@ test('WorkHub uses its coordination model and shared attachment composer', async
const close = await workhub.getByRole('button', { name: /^(隐藏|Hide)$/ }).boundingBox();
const input = await editor.boundingBox();
expect(close!.y).toBeLessThan(input!.y);
await workhub.getByRole('button', { name: /收起对话|Collapse conversation/ }).click();
await expect(workhub.locator('.workHubHistory')).toHaveCSS('opacity', '1');
const collapseFrames = await workhub.getByRole('button', { name: /收起对话|Collapse conversation/ }).evaluate((button) => new Promise<{ opacity: number; visible: boolean }[]>((resolve) => {
const history = document.querySelector('.workHubHistory')!;
const frames: { opacity: number; visible: boolean }[] = [];
const started = performance.now();
const sample = () => {
const style = getComputedStyle(history);
frames.push({ opacity: Number(style.opacity), visible: style.visibility === 'visible' });
if (performance.now() - started < 220) requestAnimationFrame(sample);
else resolve(frames);
};
(button as HTMLButtonElement).click();
requestAnimationFrame(sample);
}));
expect(collapseFrames.some((frame) => frame.visible && frame.opacity > 0 && frame.opacity < 1)).toBe(true);
await expect(workhub.locator('.workHubHistory')).toBeHidden();
await expect(workhub.getByRole('button', { name: /滚动到底部|Scroll to bottom/ })).toHaveCount(0);
await expect.poll(() => workhub.evaluate(() => window.innerHeight)).toBeLessThan(expandedHeight / 2);
Expand Down
52 changes: 50 additions & 2 deletions apps/desktop/e2e/workhub-reconstruction.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,23 @@ test('WorkHub moves the same renderer and draft between the main window and floa
await expect.poll(() => workhub.evaluate(() => window.maka.workHubPresentation.getSnapshot())).toMatchObject({ placement: 'floating', floatingVisible: true });
await expect(editor).toHaveText('Keep this unsent WorkHub draft — typed during opening');
await expect(editor).toHaveAttribute('data-test-instance', marker);
// A reused native window is already painted when focus arrives. Replaying
// the intro here would visibly fade that frame out after it appeared.
await workhub.evaluate(() => {
const samples: string[] = [];
(window as Window & { workHubFocusSamples?: string[] }).workHubFocusSamples = samples;
window.maka.workHubPresentation.onFocusComposer(() => {
requestAnimationFrame(() => samples.push(getComputedStyle(document.querySelector('.workHubComposerSurface')!).opacity));
});
});
for (let index = 0; index < 3; index++) {
await workhub.evaluate(() => window.maka.workHubPresentation.hide());
await expect(page.locator('.workHubDockPlaceholder')).toBeHidden();
await expect(workhub.locator('.workHubHistory')).toHaveCSS('pointer-events', 'auto');
await expect(editor).toHaveAttribute('data-test-instance', marker);
await page.evaluate(() => window.maka.workHubPresentation.detach());
await expect.poll(() => workhub.evaluate(() => (window as Window & { workHubFocusSamples?: string[] }).workHubFocusSamples)).toEqual(Array.from({ length: index + 1 }, () => '1'));
}
await page.evaluate(() => window.maka.settings.updateClient({ workHub: { enabled: false } }));
await expect(page.locator('.workHubDock')).toBeHidden();
await expect.poll(() => workhub.evaluate(() => window.maka.workHubPresentation.getSnapshot())).toMatchObject({ floatingVisible: false, shortcutRegistered: false });
Expand All @@ -85,8 +102,7 @@ test('WorkHub moves the same renderer and draft between the main window and floa
await workhub.getByRole('button', { name: /展开对话|Expand conversation/ }).click();
await expect(workhub.locator('.workHubHistory')).toBeVisible();
await workhub.getByRole('button', { name: /^(Hide|隐藏|隱藏)$/ }).click();
await expect.poll(() => workhub.evaluate(() => window.maka.workHubPresentation.getSnapshot())).toMatchObject({ placement: 'floating', floatingVisible: false });
await page.getByRole('button', { name: /^(Bring WorkHub back|收回工作台)$/ }).click();
await expect.poll(() => workhub.evaluate(() => window.maka.workHubPresentation.getSnapshot())).toMatchObject({ placement: 'docked', floatingVisible: false });
await expect(page.locator('.workHubDockPlaceholder')).toBeHidden();
await expect.poll(() => workhub.evaluate(() => window.maka.workHubPresentation.getSnapshot())).toMatchObject({ placement: 'docked' });
await expect(editor).toHaveText('Keep this unsent WorkHub draft — typed during opening');
Expand All @@ -107,4 +123,36 @@ test('WorkHub moves the same renderer and draft between the main window and floa
await workhub.getByRole('button', { name: '发送', exact: true }).click();
await expect(workhub.locator('.workHubLive')).toHaveAttribute('data-conversation-expanded', 'true');
await expect(workhub.locator('article').filter({ hasText: 'Keep this unsent WorkHub draft' }).first()).toBeVisible();
// WorkHub owns foreground focus while native input targets Desktop behind it.
// Playwright otherwise forces document.hasFocus() true for every page.
const main = await app.browserWindow(page);
const cdp = await app.context().newCDPSession(page);
await cdp.send('Emulation.setFocusEmulationEnabled', { enabled: false });
await main.evaluate(window => window.showInactive());
await page.evaluate(() => window.maka.workHubPresentation.detach());
await expect.poll(() => main.evaluate(window => window.isFocused())).toBe(false);
expect(await page.evaluate(() => document.hasFocus())).toBe(false);
const result = await app.evaluate(async ({ BrowserWindow }, mainId) => {
const require = process.getBuiltinModule('module').createRequire(process.cwd() + '/package.json');
const { WorkHubUi } = require('./dist/main/workhub-ui.js');
const main = BrowserWindow.fromId(mainId)!;
let focused = 0;
const onFocus = () => focused++;
main.on('focus', onFocus);
const ui = new WorkHubUi(() => main.webContents, () => main.webContents.executeJavaScript('window.maka.settings.getClient()'), () => {}, async () => '');
try {
const signal = new AbortController().signal;
await ui.begin(signal);
await ui.execute({ kind: 'open', area: 'newTask' }, signal);
const observation = await ui.observe();
const editor = observation.controls.find((item: any) => item.editable && item.name.match(/消息输入框|Message/));
if (!editor) throw new Error('Background navigation did not expose the composer');
await ui.execute({ kind: 'type', ref: editor.ref, text: '后台输入验证' }, signal);
await main.webContents.executeJavaScript("window.__backgroundKey = undefined; document.addEventListener('keydown', event => { window.__backgroundKey = { key: event.key, trusted: event.isTrusted }; }, { once: true })");
await ui.execute({ kind: 'key', ref: editor.ref, key: 'ArrowLeft' }, signal);
return { focused, mainFocused: main.isFocused(), key: await main.webContents.executeJavaScript('window.__backgroundKey'), value: await main.webContents.executeJavaScript('document.querySelector("[contenteditable=true]")?.textContent') };
} finally { main.removeListener('focus', onFocus); }
}, await main.evaluate(window => window.id));
expect(result).toEqual({ focused: 0, mainFocused: false, key: { key: 'ArrowLeft', trusted: true }, value: '后台输入验证' });
await expect(workhub.locator('[contenteditable=true]')).toBeEmpty();
});
9 changes: 4 additions & 5 deletions apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -710,7 +710,7 @@
"nonTriviaTokens": 1395
},
"src/renderer/app-shell.tsx": {
"importDeclarations": 71,
"importDeclarations": 70,
"bridgePaths": {
"window.maka.attachments": 1,
"window.maka.attachments.readBytes": 1,
Expand Down Expand Up @@ -863,7 +863,6 @@
"./use-turn-action-registry": 1,
"./workspace-readiness-recovery": 1,
"@astryxdesign/core/AppShell": 1,
"@astryxdesign/core/Button": 1,
"@maka/core/onboarding-milestone": 1,
"@maka/core/session": 1,
"@maka/core/session-revisions": 1,
Expand All @@ -872,8 +871,8 @@
"@maka/ui": 1,
"react": 1
},
"importSpecifiers": 109,
"nonTriviaTokens": 13746
"importSpecifiers": 108,
"nonTriviaTokens": 13697
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 2,
Expand Down Expand Up @@ -4657,7 +4656,7 @@
"react-dom/client": 1
},
"importSpecifiers": 7,
"nonTriviaTokens": 268
"nonTriviaTokens": 260
}
},
"rootDebtClosure": {
Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/src/main/__tests__/client-settings-effects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,22 @@ test('an explicit dark preference ignores what the OS reports', async () => {
await effects.refresh(false);
assert.deepEqual(applied, ['ink']);
});


test('applies WorkHub enable state from the supplied snapshot without another storage read', async () => {
const applied: boolean[] = [];
const effects = createClientSettingsEffects({
settingsStore: { get: async () => { throw new Error('unexpected storage read'); } },
applyWorkHub: async (enabled) => { applied.push(enabled); },
applyKeepSystemAwake: async () => undefined,
applyBotSettings: async () => undefined,
applyAppIcon: async () => undefined,
systemPrefersDark: () => false,
observeLocale: () => undefined,
emitExternalChanged: () => undefined,
});
const settings = createDefaultSettings();
await effects.apply({ ...settings, workHub: { enabled: true } }, true);
await effects.apply({ ...settings, workHub: { enabled: false } }, true);
assert.deepEqual(applied, [true, false]);
});
45 changes: 34 additions & 11 deletions apps/desktop/src/main/__tests__/workhub-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,18 +106,18 @@ test("control rejects ordinary Sessions, stale turns and switched Host epochs be
await assert.rejects(
async () =>
h.tool.impl(
{ request: { operation: "observe" } },
{ status: "Checking Maka", request: { operation: "observe" } },
{ ...h.ctx(), sessionId: "ordinary" },
),
/Only the active WorkHub/,
);
await assert.rejects(
async () => h.tool.impl({ request: { operation: "observe" } }, h.ctx("stale")),
async () => h.tool.impl({ status: "Checking Maka", request: { operation: "observe" } }, h.ctx("stale")),
/Inactive turn/,
);
h.setCurrent(false);
await assert.rejects(
async () => h.tool.impl({ request: { operation: "observe" } }, h.ctx()),
async () => h.tool.impl({ status: "Checking Maka", request: { operation: "observe" } }, h.ctx()),
/Host changed/,
);
});
Expand Down Expand Up @@ -148,7 +148,7 @@ test("partial batches never replay input and the failure budget belongs to the r
const act = async (refs = ["current"]) =>
(await h.tool.impl(
{
request: {
status: "Checking Maka", request: {
operation: "act",
actions: refs.map((ref) => ({ kind: "click", ref })),
},
Expand All @@ -157,18 +157,23 @@ test("partial batches never replay input and the failure budget belongs to the r
)) as {
completed: unknown[];
recoverable: boolean;
error: string;
inputDispatched: boolean;
requiresNewTurn?: boolean;
};
const partial = await act(["one", "two", "three"]);
assert.equal(partial.completed.length, 1);
assert.equal(attempts, 2);
assert.equal(partial.inputDispatched, false);
assert.equal((await h.command("snapshot")).error, "Control changed");
assert.equal(partial.error, "Control changed", "the model still receives the recoverable error");
assert.equal((await h.command("snapshot")).error, undefined, "recoverable tool failures are not conversation errors");
failAfterInput = true;
assert.equal((await act()).inputDispatched, true);
assert.equal((await act()).recoverable, false);
assert.equal((await act()).requiresNewTurn, true);
const blocked = await act();
assert.equal(blocked.requiresNewTurn, true);
assert.equal(blocked.error, "Control changed");
assert.equal((await h.command("snapshot")).phase, "error");
assert.equal(attempts, 4);
h.control.complete(
desktopSessionResourceKey({
Expand All @@ -178,21 +183,22 @@ test("partial batches never replay input and the failure budget belongs to the r
);
h.setTurn("new-turn");
assert.equal((await act()).recoverable, true);
assert.equal((await h.command("snapshot")).error, undefined, "a new turn does not inherit the previous terminal error");
});

test("takeover cancels waiting and interrupts the exact owning turn", async (t) => {
const h = harness();
t.after(() => h.control.close());
const pending = assert.rejects(
async () => h.tool.impl({ request: { operation: "observe", waitMs: 5000 } }, h.ctx()),
async () => h.tool.impl({ status: "Checking Maka", request: { operation: "observe", waitMs: 5000 } }, h.ctx()),
/abort|User took control/i,
);
await new Promise((resolve) => setImmediate(resolve));
await h.command("stop");
await pending;
assert.deepEqual(h.interrupted, ["turn"]);
await assert.rejects(
async () => h.tool.impl({ request: { operation: "observe" } }, h.ctx()),
async () => h.tool.impl({ status: "Checking Maka", request: { operation: "observe" } }, h.ctx()),
/User took control/,
);
});
Expand Down Expand Up @@ -408,7 +414,7 @@ test("window preparation cannot admit input after takeover or a Host switch", as
async () =>
h.tool.impl(
{
request: {
status: "Checking Maka", request: {
operation: "act",
actions: [{ kind: "navigate", section: "general" }],
},
Expand Down Expand Up @@ -456,7 +462,7 @@ test("takeover stops an action without exposing its internal abort reason as a c
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
});
});
const action = h.tool.impl({ request: { operation: "act", actions: [{ kind: "navigate", section: "general" }] } }, h.ctx());
const action = h.tool.impl({ status: "Checking Maka", request: { operation: "act", actions: [{ kind: "navigate", section: "general" }] } }, h.ctx());
await executing;
await h.command('stop');
const result = await action as { interrupted: boolean; error: string };
Expand Down Expand Up @@ -484,7 +490,7 @@ test("cancelling undo is a normal completion for the renderer", async (t) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
});
});
await h.tool.impl({ request: { operation: 'act', actions: [{ kind: 'set', target: 'theme', value: createDefaultSettings().appearance.theme }] } }, h.ctx());
await h.tool.impl({ status: "Checking Maka", request: { operation: 'act', actions: [{ kind: 'set', target: 'theme', value: createDefaultSettings().appearance.theme }] } }, h.ctx());
h.control.complete(desktopSessionResourceKey({ ...scope, sessionId: WORKHUB_COORDINATION_SESSION_ID }));
const undoing = h.command('undo');
await executing;
Expand All @@ -493,3 +499,20 @@ test("cancelling undo is a normal completion for the renderer", async (t) => {
assert.equal((await h.command('snapshot')).error, undefined);
assert.equal((await h.command('snapshot')).phase, 'paused');
});


test('control requires a short status before preparing the window and publishes the same text', async (t) => {
let prepared = 0;
const h = harness(async () => { prepared++; });
t.after(() => h.control.close());
t.mock.method(WorkHubUi.prototype, 'observe', async () => ({ section: null, language: 'en', theme: 'light', accessibility: '', controls: [] }));
for (const status of [undefined, '', ' ', 'a'.repeat(81), 'first\nsecond']) {
await assert.rejects(async () => h.tool.impl({ status, request: { operation: 'observe' } }, h.ctx()));
}
assert.equal(prepared, 0);
await h.tool.impl({ status: ' 正在检查项目设置 ', request: { operation: 'observe' } }, h.ctx());
assert.equal((await h.command('snapshot')).status, '正在检查项目设置');
assert.equal(prepared, 1);
h.control.complete(desktopSessionResourceKey({ ...scope, sessionId: WORKHUB_COORDINATION_SESSION_ID }));
assert.equal((await h.command('snapshot')).status, undefined);
});
Loading