From 13f65d2b675a9602f35f1d5964fd78a1c54fd391 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sun, 23 Aug 2026 02:36:22 +0800 Subject: [PATCH 1/6] fix(desktop): harden WorkHub review paths Refs: #3492 Generated-by: Codex --- .../main/__tests__/workhub-controller.test.ts | 977 ++++++++++++++++++ .../__tests__/workhub-session-port.test.ts | 189 ++++ .../workhub-settings-ownership.test.ts | 20 + .../__tests__/workhub-surface-flow.test.ts | 203 ++++ apps/desktop/src/renderer/app-shell.tsx | 83 +- .../locales/settings-preferences-copy.ts | 7 +- .../settings/general-settings-page.tsx | 18 + apps/desktop/src/renderer/styles.css | 1 + apps/desktop/src/renderer/styles/workhub.css | 259 +++++ .../src/renderer/workhub-controller.ts | 204 ++++ .../src/renderer/workhub-route-policy.ts | 349 +++++++ .../src/renderer/workhub-session-port.ts | 123 +++ apps/desktop/src/renderer/workhub-surface.tsx | 383 +++++++ apps/desktop/src/shared/settings-ownership.ts | 2 + docs/astryx-surface-file-inventory.md | 6 +- docs/astryx-surface-file-inventory.paths | 2 + docs/workhub-domain-language.md | 17 + packages/core/src/__tests__/settings.test.ts | 10 + packages/core/src/settings.ts | 18 + packages/ui/src/session-list-panel.tsx | 6 + packages/ui/src/session-sidebar-nav.tsx | 16 +- 21 files changed, 2883 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/workhub-controller.test.ts create mode 100644 apps/desktop/src/main/__tests__/workhub-session-port.test.ts create mode 100644 apps/desktop/src/main/__tests__/workhub-settings-ownership.test.ts create mode 100644 apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts create mode 100644 apps/desktop/src/renderer/styles/workhub.css create mode 100644 apps/desktop/src/renderer/workhub-controller.ts create mode 100644 apps/desktop/src/renderer/workhub-route-policy.ts create mode 100644 apps/desktop/src/renderer/workhub-session-port.ts create mode 100644 apps/desktop/src/renderer/workhub-surface.tsx create mode 100644 docs/workhub-domain-language.md diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts new file mode 100644 index 0000000000..032a1ceffe --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -0,0 +1,977 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createWorkHubController, + WORKHUB_ROUTING_STRATEGY_ID, + type WorkHubSessionFacts, + type WorkHubSessionPort, +} from '../../renderer/workhub-controller.js'; + +test('binds the controller to the immutable WH-R2.3 strategy ID', () => { + assert.equal(WORKHUB_ROUTING_STRATEGY_ID, 'wh-r2.3-session-core-evidence'); +}); + +function session( + sessionId: string, + overrides: Partial = {}, +): WorkHubSessionFacts { + return { + target: { sessionId }, + projectName: 'maka', + sessionName: sessionId, + kind: 'ordinary', + archived: false, + state: 'active', + updatedAt: 1, + ...overrides, + }; +} + +function port(sessions: WorkHubSessionFacts[]): WorkHubSessionPort { + return { + list: async () => sessions, + routingEvidence: async () => [], + create: async () => { + throw new Error('create is not used by this read test'); + }, + submit: async () => { + throw new Error('submit is not used by this read test'); + }, + stop: async () => {}, + subscribe: () => () => {}, + }; +} + +test('read exposes existing ordinary Sessions as factual Work summaries', async () => { + const controller = createWorkHubController({ + sessions: port([ + session('login', { + sessionName: '登录刷新令牌', + state: 'running', + latestResult: '已定位到刷新竞争条件', + updatedAt: 30, + }), + session('payment', { + projectName: 'billing', + sessionName: '支付回调幂等性', + archived: true, + latestResult: '处理支付回调重复投递', + updatedAt: 20, + }), + session('hub-internal', { kind: 'internal', updatedAt: 50 }), + session('child-agent', { kind: 'subagent', updatedAt: 40 }), + ]), + }); + + const projection = await controller.read(); + + assert.deepEqual(projection.sessions, [ + { + target: { sessionId: 'login' }, + projectName: 'maka', + sessionName: '登录刷新令牌', + archived: false, + state: 'running', + latestResult: '已定位到刷新竞争条件', + updatedAt: 30, + }, + { + target: { sessionId: 'payment' }, + projectName: 'billing', + sessionName: '支付回调幂等性', + archived: true, + state: 'active', + latestResult: '处理支付回调重复投递', + updatedAt: 20, + }, + ]); +}); + +test('archived Sessions stay inspectable but are excluded from routing targets', async () => { + const evidenceTargets: string[][] = []; + const submitted: string[] = []; + const sessions = port([ + session('archived-payment', { + sessionName: '支付回调幂等性', + archived: true, + updatedAt: 30, + }), + session('active-login', { + sessionName: '登录刷新令牌', + updatedAt: 20, + }), + ]); + sessions.routingEvidence = async (targets) => { + evidenceTargets.push(targets.map((target) => target.sessionId)); + return []; + }; + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: 'unexpected' }; + }; + const controller = createWorkHubController({ sessions }); + + const projection = await controller.read(); + const result = await controller.submit({ + requestId: 'archived-target', + text: '支付回调幂等性现在是什么状态?', + }); + + assert.equal(projection.sessions.some((entry) => entry.archived), true); + assert.deepEqual(evidenceTargets, [['active-login']]); + assert.equal(result.kind, 'discussion'); + assert.deepEqual(submitted, []); +}); + +test('submit sends an explicitly targeted request to that Session', async () => { + const submitted: Array<{ sessionId: string; text: string }> = []; + const sessions = port([session('payment', { sessionName: '支付回调幂等性' })]); + sessions.submit = async (target, text) => { + submitted.push({ sessionId: target.sessionId, text }); + return { turnId: 'turn-payment' }; + }; + const controller = createWorkHubController({ sessions }); + + const result = await controller.submit({ + requestId: 'request-1', + text: '补充重复投递测试', + explicitTarget: { sessionId: 'payment' }, + }); + + assert.deepEqual(result, { + kind: 'submitted', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: 'request-1', + target: { sessionId: 'payment' }, + turnId: 'turn-payment', + evidence: 'explicit_target', + }); + assert.deepEqual(submitted, [ + { sessionId: 'payment', text: '补充重复投递测试' }, + ]); +}); + +test('submit routes a unique complete Session name without asking', async () => { + const submitted: string[] = []; + const sessions = port([ + session('login', { sessionName: '登录刷新令牌' }), + session('payment', { projectName: 'billing', sessionName: '支付回调幂等性' }), + ]); + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: 'turn-exact' }; + }; + const controller = createWorkHubController({ sessions }); + + const result = await controller.submit({ + requestId: 'request-exact', + text: '在支付回调幂等性里补充重复投递测试', + }); + + assert.deepEqual(result, { + kind: 'submitted', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: 'request-exact', + target: { sessionId: 'payment' }, + turnId: 'turn-exact', + evidence: 'exact_session_name', + }); + assert.deepEqual(submitted, ['payment']); +}); + +test('a unique longer Session name outranks a generic contained Session name', async () => { + const submitted: string[] = []; + const sessions = port([ + session('layout', { sessionName: '优化WorkHub移动端消息布局' }), + session('generic', { sessionName: 'WorkHub' }), + ]); + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: 'turn-layout' }; + }; + + const result = await createWorkHubController({ sessions }).submit({ + requestId: 'request-layout', + text: '优化WorkHub移动端消息布局:补充横屏注意点。', + }); + + assert.equal(result.kind, 'submitted'); + assert.deepEqual(submitted, ['layout']); +}); + +test('submit asks the user when weak relevance matches more than one Session', async () => { + const submitted: string[] = []; + const sessions = port([ + session('login', { + sessionName: '登录刷新令牌', + latestResult: '处理刷新令牌过期造成的重复登录', + updatedAt: 20, + }), + session('payment', { + projectName: 'billing', + sessionName: '支付回调幂等性', + latestResult: '处理支付回调重复投递', + updatedAt: 30, + }), + ]); + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: 'unexpected' }; + }; + const controller = createWorkHubController({ sessions }); + + const result = await controller.submit({ + requestId: 'request-ambiguous', + text: '继续处理重复问题', + }); + + assert.deepEqual(result, { + kind: 'clarification', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: 'request-ambiguous', + text: '继续处理重复问题', + options: [ + { + target: { sessionId: 'payment' }, + projectName: 'billing', + sessionName: '支付回调幂等性', + }, + { + target: { sessionId: 'login' }, + projectName: 'maka', + sessionName: '登录刷新令牌', + }, + ], + }); + assert.deepEqual(submitted, []); +}); + +test('submit keeps origin prompts as stable evidence after latest results change', async () => { + const sessions = port([ + session('login', { + sessionName: '登录刷新令牌', + latestResult: '已经整理为检查清单', + updatedAt: 20, + }), + session('payment', { + sessionName: '支付回调幂等性', + latestResult: '已经把风险按高、中、低分组', + updatedAt: 30, + }), + ]); + sessions.routingEvidence = async () => [ + { + target: { sessionId: 'login' }, + originPrompt: '排查刷新令牌过期导致的重复登录', + }, + { + target: { sessionId: 'payment' }, + originPrompt: '检查支付回调重复投递时的幂等性', + }, + ]; + sessions.submit = async () => ({ turnId: 'turn-focus-login' }); + const controller = createWorkHubController({ sessions }); + await controller.submit({ + requestId: 'request-focus-login', + text: '先看登录', + explicitTarget: { sessionId: 'login' }, + }); + + const result = await controller.submit({ + requestId: 'request-origin-ambiguity', + text: '继续处理重复问题', + }); + + assert.equal(result.kind, 'clarification'); + assert.deepEqual(result.kind === 'clarification' + ? result.options.map((option) => option.target.sessionId) + : [], ['payment', 'login']); +}); + +test('submit creates a new executable topic instead of following one weak old clue', async () => { + const createdNames: string[] = []; + const sessions = port([ + session('login', { + sessionName: '登录刷新令牌', + latestResult: '已经整理为检查清单', + }), + ]); + sessions.routingEvidence = async () => [{ + target: { sessionId: 'login' }, + originPrompt: '排查刷新令牌过期导致的重复登录', + }]; + sessions.create = async ({ name }) => { + createdNames.push(name); + return session('payment-new', { sessionName: name }); + }; + sessions.submit = async () => ({ turnId: 'turn-payment-new' }); + const controller = createWorkHubController({ sessions }); + const text = '检查支付回调重复投递时的幂等性,先只分析风险和测试点,不修改文件。'; + + const result = await controller.submit({ requestId: 'request-payment-new', text }); + + assert.equal(result.kind, 'submitted'); + assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { + sessionId: 'payment-new', + }); + assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); + assert.deepEqual(createdNames, ['检查支付回调重复投递时的幂等性']); +}); + +test('submit does not treat a project name as strong topic evidence', async () => { + const createdNames: string[] = []; + const sessions = port([ + session('login', { + projectName: 'maka-workhub-session-router', + sessionName: '登录刷新令牌', + }), + ]); + sessions.routingEvidence = async () => [{ + target: { sessionId: 'login' }, + originPrompt: '排查刷新令牌过期导致的重复登录', + }]; + sessions.create = async ({ name }) => { + createdNames.push(name); + return session('layout-new', { sessionName: name }); + }; + sessions.submit = async () => ({ turnId: 'turn-layout-new' }); + const controller = createWorkHubController({ sessions }); + const text = '优化 WorkHub 在移动端窄屏下的消息布局,先给设计建议,不修改文件。'; + + const result = await controller.submit({ requestId: 'request-layout-new', text }); + + assert.equal(result.kind, 'submitted'); + assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { + sessionId: 'layout-new', + }); + assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); + assert.deepEqual(createdNames, ['优化 WorkHub 在移动端窄屏下的消息布局']); +}); + +test('submit follows an unambiguous reference to the most recent Work', async () => { + const submitted: string[] = []; + const sessions = port([ + session('login', { sessionName: '登录刷新令牌' }), + session('payment', { sessionName: '支付回调幂等性' }), + ]); + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: `turn-${submitted.length}` }; + }; + const controller = createWorkHubController({ sessions }); + await controller.submit({ + requestId: 'request-focus', + text: '先处理支付', + explicitTarget: { sessionId: 'payment' }, + }); + + const result = await controller.submit({ + requestId: 'request-pronoun', + text: '继续它', + }); + + assert.deepEqual(result, { + kind: 'submitted', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: 'request-pronoun', + target: { sessionId: 'payment' }, + turnId: 'turn-2', + evidence: 'recent_focus', + }); + assert.deepEqual(submitted, ['payment', 'payment']); +}); + +test('submit routes strong core evidence instead of reusing recent focus', async () => { + const submitted: string[] = []; + const sessions = port([ + session('login', { + sessionName: '登录稳定性', + latestResult: '处理刷新令牌重复登录', + }), + session('payment', { + sessionName: '支付稳定性', + latestResult: '处理支付回调重复投递', + }), + ]); + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: `turn-${submitted.length}` }; + }; + const controller = createWorkHubController({ sessions }); + await controller.submit({ + requestId: 'request-login-focus', + text: '先看登录', + explicitTarget: { sessionId: 'login' }, + }); + + const result = await controller.submit({ + requestId: 'request-topic-shift', + text: '继续处理支付回调重复投递', + }); + + assert.deepEqual(result, { + kind: 'submitted', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: 'request-topic-shift', + target: { sessionId: 'payment' }, + turnId: 'turn-2', + evidence: 'core_entity', + }); + assert.deepEqual(submitted, ['login', 'payment']); +}); + +test('submit routes unique strong core evidence without asking', async () => { + const submitted: string[] = []; + const sessions = port([ + session('login', { + sessionName: '登录稳定性', + latestResult: '处理刷新令牌过期导致的重复登录', + }), + session('payment', { + sessionName: '支付稳定性', + latestResult: '处理支付回调重复投递', + }), + ]); + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: 'turn-core' }; + }; + const controller = createWorkHubController({ sessions }); + + const result = await controller.submit({ + requestId: 'request-core', + text: '刷新令牌过期时,重复登录的观测日志应该记录哪些字段?', + }); + + assert.equal(result.kind, 'submitted'); + if (result.kind !== 'submitted') return; + assert.deepEqual(result.target, { sessionId: 'login' }); + assert.equal(result.evidence, 'core_entity'); + assert.equal(result.strategyId, 'wh-r2.3-session-core-evidence'); + assert.deepEqual(submitted, ['login']); +}); + +test('submit ignores shared boilerplate when an executable request names a new topic', async () => { + const createdNames: string[] = []; + const sessions = port([ + session('login', { + sessionName: '登录刷新令牌', + latestResult: '排查登录刷新令牌,先只分析风险和测试点,不修改文件', + }), + ]); + sessions.create = async ({ name }) => { + createdNames.push(name); + return session('payment-new', { sessionName: name }); + }; + sessions.submit = async () => ({ turnId: 'turn-payment-new' }); + const controller = createWorkHubController({ sessions }); + const text = '请创建新任务,检查支付回调重复投递;先只分析风险和测试点,不修改文件。'; + + const result = await controller.submit({ requestId: 'request-new-topic', text }); + + assert.equal(result.kind, 'submitted'); + if (result.kind !== 'submitted') return; + assert.deepEqual(result.target, { sessionId: 'payment-new' }); + assert.equal(result.evidence, 'new_session'); + assert.equal(result.strategyId, 'wh-r2.3-session-core-evidence'); + assert.deepEqual(createdNames, ['检查支付回调重复投递']); +}); + +test('submit keeps a unique two-character clue behind clarification', async () => { + const sessions = port([ + session('login', { sessionName: '登录稳定性' }), + session('payment', { sessionName: '支付稳定性' }), + ]); + const controller = createWorkHubController({ sessions }); + + const result = await controller.submit({ + requestId: 'request-weak', + text: '继续登录', + }); + + assert.equal(result.kind, 'clarification'); + assert.equal(result.strategyId, 'wh-r2.3-session-core-evidence'); +}); + +test('submit treats explicit user uncertainty as clarification instead of a new Session', async () => { + const created: string[] = []; + const sessions = port([ + session('login', { sessionName: '登录稳定性', updatedAt: 20 }), + session('payment', { sessionName: '支付稳定性', updatedAt: 30 }), + ]); + sessions.create = async ({ name }) => { + created.push(name); + return session('unexpected'); + }; + const controller = createWorkHubController({ sessions }); + + const result = await controller.submit({ + requestId: 'request-uncertain', + text: '继续处理稳定性问题,但我不确定具体是哪一个。', + }); + + assert.equal(result.kind, 'clarification'); + assert.deepEqual(result.kind === 'clarification' + ? result.options.map((option) => option.target.sessionId) + : [], ['payment', 'login']); + assert.deepEqual(created, []); +}); + +test('English target uncertainty uses clarification as the routing safety valve', async () => { + const submitted: string[] = []; + const sessions = port([ + session('parser', { sessionName: 'Parser Cleanup', updatedAt: 20 }), + session('profile', { sessionName: 'Profile Settings', updatedAt: 30 }), + ]); + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: 'unexpected' }; + }; + + const result = await createWorkHubController({ sessions }).submit({ + requestId: 'english-uncertainty', + text: "I'm not sure which one this belongs to; continue the cleanup.", + }); + + assert.equal(result.kind, 'clarification'); + assert.deepEqual(result.kind === 'clarification' + ? result.options.map((option) => option.target.sessionId) + : [], ['parser', 'profile']); + assert.deepEqual(submitted, []); +}); + +test('English routing matches whole words instead of substrings in another identity', async () => { + const submitted: string[] = []; + const created: string[] = []; + const sessions = port([ + session('profile', { sessionName: 'Profile Settings' }), + ]); + sessions.create = async ({ name }) => { + created.push(name); + return session('parser-new', { sessionName: name }); + }; + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: 'turn-parser' }; + }; + + const result = await createWorkHubController({ sessions }).submit({ + requestId: 'english-word-boundary', + text: 'check the file parser', + }); + + assert.equal(result.kind, 'submitted'); + assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); + assert.deepEqual(created, ['check the file parser']); + assert.deepEqual(submitted, ['parser-new']); +}); + +test('English core evidence requires a distinctive word or multiple whole-word matches', async () => { + const submitted: string[] = []; + const sessions = port([ + session('parser', { + sessionName: 'Parser Cleanup', + latestResult: 'Tokenizer regression isolated in parser recovery', + }), + session('profile', { + sessionName: 'Profile Settings', + latestResult: 'Account preferences are ready', + }), + ]); + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: 'turn-parser' }; + }; + + const result = await createWorkHubController({ sessions }).submit({ + requestId: 'english-core-evidence', + text: 'fix the parser tokenizer crash', + }); + + assert.equal(result.kind, 'submitted'); + assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'core_entity'); + assert.deepEqual(submitted, ['parser']); +}); + +test('route correction stops the wrong Session and teaches a similar request', async () => { + const submitted: string[] = []; + const stopped: string[] = []; + const sessions = port([ + session('login', { sessionName: '登录稳定性' }), + session('payment', { sessionName: '支付稳定性' }), + ]); + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: `turn-${submitted.length}` }; + }; + sessions.stop = async (target) => { + stopped.push(target.sessionId); + }; + const controller = createWorkHubController({ sessions }); + await controller.submit({ + requestId: 'request-focus-payment', + text: '先看支付', + explicitTarget: { sessionId: 'payment' }, + }); + + const wrong = await controller.submit({ + requestId: 'request-alias', + text: '继续白鹭点,列出验收项。', + }); + assert.deepEqual(wrong.kind === 'submitted' ? wrong.target : undefined, { + sessionId: 'payment', + }); + + const corrected = await controller.submit({ + requestId: 'request-alias', + text: '继续白鹭点,列出验收项。', + explicitTarget: { sessionId: 'login' }, + correction: { from: { sessionId: 'payment' } }, + }); + assert.equal(corrected.kind, 'submitted'); + assert.equal(corrected.kind === 'submitted' ? corrected.evidence : undefined, 'route_correction'); + assert.deepEqual(corrected.kind === 'submitted' ? corrected.correctedFrom : undefined, { + sessionId: 'payment', + }); + + const learned = await controller.submit({ + requestId: 'request-alias-similar', + text: '继续白鹭点,补充失败判定。', + }); + assert.deepEqual(learned.kind === 'submitted' ? learned.target : undefined, { + sessionId: 'login', + }); + assert.equal(learned.kind === 'submitted' ? learned.evidence : undefined, 'route_correction'); + assert.deepEqual(stopped, ['payment']); + assert.deepEqual(submitted, ['payment', 'payment', 'login', 'login']); +}); + +test('latest route correction wins for the same expression family', async () => { + const sessions = port([ + session('login', { sessionName: '登录稳定性' }), + session('payment', { sessionName: '支付稳定性' }), + ]); + sessions.submit = async (_target) => ({ turnId: 'turn' }); + const controller = createWorkHubController({ sessions }); + + await controller.submit({ + requestId: 'correction-login', + text: '继续白鹭点,列出验收项。', + explicitTarget: { sessionId: 'login' }, + correction: { from: { sessionId: 'payment' } }, + }); + await controller.submit({ + requestId: 'correction-payment', + text: '继续白鹭点,列出异常项。', + explicitTarget: { sessionId: 'payment' }, + correction: { from: { sessionId: 'login' } }, + }); + + const result = await controller.submit({ + requestId: 'correction-latest', + text: '继续白鹭点,补充回滚条件。', + }); + + assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { + sessionId: 'payment', + }); + assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'route_correction'); +}); + +test('waiting Session rejects a second root request without calling submit', async () => { + let submitted = false; + const sessions = port([ + session('login', { + sessionName: '排查令牌过期重复登录问题', + state: 'waiting_for_user', + }), + ]); + sessions.submit = async () => { + submitted = true; + return { turnId: 'unexpected' }; + }; + const controller = createWorkHubController({ sessions }); + + const result = await controller.submit({ + requestId: 'request-waiting', + text: '排查令牌过期重复登录问题:补充一条等待状态下的新请求。', + }); + + assert.deepEqual(result, { + kind: 'waiting', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: 'request-waiting', + text: '排查令牌过期重复登录问题:补充一条等待状态下的新请求。', + target: { sessionId: 'login' }, + }); + assert.equal(submitted, false); +}); + +test('submit returns to the previous focused Session', async () => { + const submitted: string[] = []; + const sessions = port([ + session('login', { sessionName: '登录刷新令牌' }), + session('payment', { sessionName: '支付回调幂等性' }), + ]); + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: `turn-${submitted.length}` }; + }; + const controller = createWorkHubController({ sessions }); + await controller.submit({ + requestId: 'request-login', + text: '先看登录', + explicitTarget: { sessionId: 'login' }, + }); + await controller.submit({ + requestId: 'request-payment', + text: '再看支付', + explicitTarget: { sessionId: 'payment' }, + }); + + const result = await controller.submit({ + requestId: 'request-previous', + text: '回到上一个工作', + }); + + assert.equal(result.kind, 'submitted'); + assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { sessionId: 'login' }); + assert.deepEqual(submitted, ['login', 'payment', 'login']); +}); + +test('submit lets strong foreign core evidence override a vague focus word', async () => { + const submitted: string[] = []; + const sessions = port([ + session('login', { + sessionName: '登录稳定性', + latestResult: '处理刷新令牌过期导致的重复登录', + }), + session('payment', { + sessionName: '支付稳定性', + latestResult: '处理支付回调重复投递', + }), + ]); + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: `turn-${submitted.length}` }; + }; + const controller = createWorkHubController({ sessions }); + await controller.submit({ + requestId: 'request-payment-focus', + text: '先看支付', + explicitTarget: { sessionId: 'payment' }, + }); + + const result = await controller.submit({ + requestId: 'request-foreign-core', + text: '继续处理刷新令牌过期', + }); + + assert.equal(result.kind, 'submitted'); + assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { sessionId: 'login' }); + assert.deepEqual(submitted, ['payment', 'login']); +}); + +test('submit keeps unmatched non-executable conversation in WorkHub', async () => { + let created = false; + const sessions = port([]); + sessions.create = async () => { + created = true; + return session('unexpected'); + }; + const controller = createWorkHubController({ sessions }); + + const result = await controller.submit({ + requestId: 'request-discussion', + text: '你觉得统一入口最重要的价值是什么?', + }); + + assert.deepEqual(result, { + kind: 'discussion', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: 'request-discussion', + text: '你觉得统一入口最重要的价值是什么?', + }); + assert.equal(created, false); +}); + +test('submit treats a design question containing an action word as discussion', async () => { + let created = false; + const sessions = port([]); + sessions.create = async () => { + created = true; + return session('unexpected'); + }; + const controller = createWorkHubController({ sessions }); + + const result = await controller.submit({ + requestId: 'request-design-question', + text: '我们应该怎么实现统一入口?', + }); + + assert.equal(result.kind, 'discussion'); + assert.equal(created, false); +}); + +test('an executable English request may contain what without becoming discussion', async () => { + const created: string[] = []; + const sessions = port([]); + sessions.create = async ({ name }) => { + created.push(name); + return session('parser-fix', { sessionName: name }); + }; + sessions.submit = async () => ({ turnId: 'turn-parser-fix' }); + + const result = await createWorkHubController({ sessions }).submit({ + requestId: 'english-what-object', + text: 'fix what is broken in the parser', + }); + + assert.equal(result.kind, 'submitted'); + assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); + assert.deepEqual(created, ['fix what is broken in the parser']); +}); + +test('submit creates an ordinary Session for a clear unmatched executable goal', async () => { + const createdNames: string[] = []; + const submitted: Array<{ sessionId: string; text: string }> = []; + const sessions = port([]); + sessions.create = async ({ name }) => { + createdNames.push(name); + return session('invoice-export', { sessionName: name }); + }; + sessions.submit = async (target, text) => { + submitted.push({ sessionId: target.sessionId, text }); + return { turnId: 'turn-invoice-export' }; + }; + const controller = createWorkHubController({ sessions }); + + const result = await controller.submit({ + requestId: 'request-new-work', + text: '实现导出发票 PDF 功能', + }); + + assert.deepEqual(result, { + kind: 'submitted', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: 'request-new-work', + target: { sessionId: 'invoice-export' }, + turnId: 'turn-invoice-export', + evidence: 'new_session', + }); + assert.deepEqual(createdNames, ['实现导出发票 PDF 功能']); + assert.deepEqual(submitted, [ + { sessionId: 'invoice-export', text: '实现导出发票 PDF 功能' }, + ]); +}); + +test('explicit new-Session intent outranks generic evidence from existing work', async () => { + const created: string[] = []; + const sessions = port([ + session('login', { sessionName: '登录稳定性测试计划' }), + session('payment', { sessionName: '支付回调测试计划' }), + ]); + sessions.create = async ({ name }) => { + created.push(name); + return session('new-session', { sessionName: name }); + }; + sessions.submit = async () => ({ turnId: 'turn-new-session' }); + + const result = await createWorkHubController({ sessions }).submit({ + requestId: 'request-explicit-new', + text: '创建一个全新的普通 Session,标题为 R2.3 新建工作验收,只记录测试计划。', + }); + + assert.equal(result.kind, 'submitted'); + assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); + assert.deepEqual(created, ['R2.3 新建工作验收']); +}); + +test('English explicit creation extracts the requested Session name', async () => { + const created: string[] = []; + const sessions = port([]); + sessions.create = async ({ name }) => { + created.push(name); + return session('parser-cleanup', { sessionName: name }); + }; + sessions.submit = async () => ({ turnId: 'turn-parser-cleanup' }); + + const result = await createWorkHubController({ sessions }).submit({ + requestId: 'english-explicit-new', + text: 'Create a new session called Parser Cleanup.', + }); + + assert.equal(result.kind, 'submitted'); + assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); + assert.deepEqual(created, ['Parser Cleanup']); +}); + +test('English routing boilerplate does not make an old analysis look related', async () => { + const created: string[] = []; + const sessions = port([ + session('login', { + sessionName: 'Login Refresh Token', + latestResult: 'Just analyze the risks and test cases; do not modify any files.', + }), + ]); + sessions.create = async ({ name }) => { + created.push(name); + return session('payment-new', { sessionName: name }); + }; + sessions.submit = async () => ({ turnId: 'turn-payment-new' }); + + const result = await createWorkHubController({ sessions }).submit({ + requestId: 'english-boilerplate', + text: "Check payment callback duplicate delivery; just analyze the risks and test cases; don't modify any files.", + }); + + assert.equal(result.kind, 'submitted'); + assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); + assert.deepEqual(created, ['Check payment callback duplicate delivery']); +}); + +test('negated and deliberative creation language never creates a Session', async () => { + const created: string[] = []; + const sessions = port([]); + sessions.create = async ({ name }) => { + created.push(name); + return session('unexpected', { sessionName: name }); + }; + const controller = createWorkHubController({ sessions }); + + const negated = await controller.submit({ + requestId: 'negated-create', + text: '不要创建一个新任务,我们先讨论这个方向。', + }); + const deliberative = await controller.submit({ + requestId: 'question-create', + text: '是否应该新建一个任务?', + }); + + assert.equal(negated.kind, 'discussion'); + assert.equal(deliberative.kind, 'discussion'); + assert.deepEqual(created, []); +}); + +test('subscribe exposes Session invalidations without inventing WorkHub state', () => { + let listener: (() => void) | undefined; + let unsubscribed = false; + const sessions = port([]); + sessions.subscribe = (handler) => { + listener = handler; + return () => { + unsubscribed = true; + }; + }; + const controller = createWorkHubController({ sessions }); + let invalidations = 0; + + const unsubscribe = controller.subscribe(() => { + invalidations += 1; + }); + listener?.(); + unsubscribe(); + + assert.equal(invalidations, 1); + assert.equal(unsubscribed, true); +}); diff --git a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts new file mode 100644 index 0000000000..d5d96f06dd --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createDesktopWorkHubSessionPort, + type WorkHubDesktopSession, +} from '../../renderer/workhub-session-port.js'; + +function desktopSession( + id: string, + overrides: Partial = {}, +): WorkHubDesktopSession { + return { + id, + name: id, + labels: [], + isArchived: false, + status: 'active', + runningTurnIds: [], + projectId: 'project-maka', + lastMessageAt: 1, + ...overrides, + }; +} + +test('desktop adapter projects Session catalog facts without owning copies', async () => { + const source = [ + desktopSession('ordinary', { + name: '支付回调幂等性', + status: 'running', + runningTurnIds: ['turn-running'], + lastMessageAt: 30, + lastMessagePreview: '正在补充重复投递测试', + }), + desktopSession('side', { + labels: ['mode:side_conversation'], + lastMessageAt: 20, + }), + desktopSession('waiting', { + status: 'waiting_for_user', + runningTurnIds: ['turn-waiting'], + lastMessageAt: 15, + }), + desktopSession('child', { + subagent: {}, + lastMessageAt: 10, + }), + ]; + const adapter = createDesktopWorkHubSessionPort({ + sessions: { + list: async () => source, + listTurns: async () => [], + create: async () => { + throw new Error('not used'); + }, + send: async () => { + throw new Error('not used'); + }, + stop: async () => {}, + subscribeChanges: () => () => {}, + }, + projectName: (projectId) => projectId === 'project-maka' ? 'Maka' : undefined, + newTurnId: () => 'unused', + }); + + assert.deepEqual(await adapter.list(), [ + { + target: { sessionId: 'ordinary' }, + projectName: 'Maka', + sessionName: '支付回调幂等性', + kind: 'ordinary', + archived: false, + state: 'running', + latestResult: '正在补充重复投递测试', + updatedAt: 30, + }, + { + target: { sessionId: 'side' }, + projectName: 'Maka', + sessionName: 'side', + kind: 'internal', + archived: false, + state: 'active', + updatedAt: 20, + }, + { + target: { sessionId: 'waiting' }, + projectName: 'Maka', + sessionName: 'waiting', + kind: 'ordinary', + archived: false, + state: 'waiting_for_user', + updatedAt: 15, + }, + { + target: { sessionId: 'child' }, + projectName: 'Maka', + sessionName: 'child', + kind: 'subagent', + archived: false, + state: 'active', + updatedAt: 10, + }, + ]); +}); + +test('desktop adapter delegates create, send, and invalidation to Session APIs', async () => { + const calls: unknown[] = []; + let onChanged: (() => void) | undefined; + const adapter = createDesktopWorkHubSessionPort({ + sessions: { + list: async () => [], + listTurns: async () => [], + create: async (input) => { + calls.push(['create', input]); + return desktopSession('created', { name: input.name }); + }, + send: async (sessionId, command) => { + calls.push(['send', sessionId, command]); + return { ok: true, turnId: command.turnId }; + }, + stop: async (sessionId, input) => { + calls.push(['stop', sessionId, input]); + }, + subscribeChanges: (handler) => { + onChanged = handler; + return () => calls.push(['unsubscribe']); + }, + }, + projectName: () => 'Maka', + newTurnId: () => 'turn-new', + }); + + const created = await adapter.create({ name: '实现导出发票 PDF 功能' }); + const turn = await adapter.submit(created.target, '实现导出发票 PDF 功能'); + await adapter.stop(created.target); + let invalidations = 0; + const unsubscribe = adapter.subscribe(() => { + invalidations += 1; + }); + onChanged?.(); + unsubscribe(); + + assert.equal(created.kind, 'ordinary'); + assert.deepEqual(turn, { turnId: 'turn-new' }); + assert.equal(invalidations, 1); + assert.deepEqual(calls, [ + ['create', { name: '实现导出发票 PDF 功能' }], + ['send', 'created', { type: 'send', turnId: 'turn-new', text: '实现导出发票 PDF 功能' }], + ['stop', 'created', { source: 'stop_button' }], + ['unsubscribe'], + ]); +}); + +test('desktop adapter derives stable origin evidence from the existing Session log', async () => { + let reads = 0; + const adapter = createDesktopWorkHubSessionPort({ + sessions: { + list: async () => [], + listTurns: async (sessionId) => { + reads += 1; + assert.equal(sessionId, 'payment'); + return [ + { userPromptPreview: '检查支付回调重复投递时的幂等性' }, + { userPromptPreview: '把风险按高、中、低分组' }, + ]; + }, + create: async () => { + throw new Error('not used'); + }, + send: async () => { + throw new Error('not used'); + }, + stop: async () => {}, + subscribeChanges: () => () => {}, + }, + projectName: () => 'Maka', + newTurnId: () => 'unused', + }); + + const first = await adapter.routingEvidence([{ sessionId: 'payment' }]); + const second = await adapter.routingEvidence([{ sessionId: 'payment' }]); + + assert.deepEqual(first, [{ + target: { sessionId: 'payment' }, + originPrompt: '检查支付回调重复投递时的幂等性', + }]); + assert.deepEqual(second, first); + assert.equal(reads, 1); +}); diff --git a/apps/desktop/src/main/__tests__/workhub-settings-ownership.test.ts b/apps/desktop/src/main/__tests__/workhub-settings-ownership.test.ts new file mode 100644 index 0000000000..226d9e251a --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-settings-ownership.test.ts @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createDefaultSettings } from '@maka/core/settings'; +import { + clientOwnedSettingsPatch, + hasRuntimeHostSettingsPatch, + projectClientOwnedSettings, +} from '../../shared/settings-ownership.js'; + +test('keeps the WorkHub opt-in client-global across Runtime Hosts', () => { + assert.deepEqual(clientOwnedSettingsPatch({ workHub: { enabled: true } }), { + workHub: { enabled: true }, + }); + assert.equal(hasRuntimeHostSettingsPatch({ workHub: { enabled: true } }), false); + + const client = createDefaultSettings(); + client.workHub.enabled = true; + const runtimeHost = createDefaultSettings(); + assert.equal(projectClientOwnedSettings(runtimeHost, client).workHub.enabled, true); +}); diff --git a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts new file mode 100644 index 0000000000..45417f1447 --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -0,0 +1,203 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + WorkHubSurfaceRouteGate, + submitWorkHubSurfaceInput, + workHubSubmissionClearsDraft, +} from '../../renderer/workhub-surface.js'; +import { + createWorkHubController, + type WorkHubController, + type WorkHubSubmitInput, +} from '../../renderer/workhub-controller.js'; +import { + createDesktopWorkHubSessionPort, + type WorkHubDesktopSession, +} from '../../renderer/workhub-session-port.js'; + +test('surface route gate rejects same-frame duplicate operations and reopens after settle', async () => { + const gate = new WorkHubSurfaceRouteGate(); + let release: (() => void) | undefined; + const first = gate.run(async () => { + await new Promise((resolve) => { + release = resolve; + }); + return 'first'; + }); + + assert.equal(gate.pending, true); + assert.equal(await gate.run(async () => 'duplicate'), undefined); + release?.(); + assert.equal(await first, 'first'); + assert.equal(gate.pending, false); + assert.equal(await gate.run(async () => 'next'), 'next'); +}); + +test('surface keeps the Composer draft when routing fails or the target is waiting', () => { + assert.equal(workHubSubmissionClearsDraft(undefined), false); + assert.equal(workHubSubmissionClearsDraft({ + kind: 'waiting', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: 'waiting', + text: '继续处理', + target: { sessionId: 'payment' }, + }), false); + assert.equal(workHubSubmissionClearsDraft({ + kind: 'discussion', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: 'discussion', + text: '先讨论方向', + }), true); +}); + +test('surface keeps clarification and successful routing in WorkHub', async () => { + const submissions: WorkHubSubmitInput[] = []; + const controller: WorkHubController = { + read: async () => ({ sessions: [] }), + subscribe: () => () => {}, + submit: async (input) => { + submissions.push(input); + if (!input.explicitTarget) { + return { + kind: 'clarification', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: input.requestId, + text: input.text, + options: [{ + target: { sessionId: 'payment' }, + projectName: 'billing', + sessionName: '支付回调幂等性', + }], + }; + } + return { + kind: 'submitted', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: input.requestId, + target: input.explicitTarget, + turnId: 'turn-payment', + evidence: 'explicit_target', + }; + }, + }; + + const clarification = await submitWorkHubSurfaceInput({ + controller, + input: { requestId: 'request-1', text: '继续处理重复问题' }, + }); + assert.equal(clarification.kind, 'clarification'); + + const submitted = await submitWorkHubSurfaceInput({ + controller, + input: { + requestId: 'request-1', + text: '继续处理重复问题', + explicitTarget: { sessionId: 'payment' }, + }, + }); + assert.equal(submitted.kind, 'submitted'); + assert.deepEqual(submissions[1]?.explicitTarget, { sessionId: 'payment' }); +}); + +test('surface leaves discussion in WorkHub instead of creating a task view', async () => { + const controller: WorkHubController = { + read: async () => ({ sessions: [] }), + subscribe: () => () => {}, + submit: async (input) => ({ + kind: 'discussion', + strategyId: 'wh-r2.3-session-core-evidence', + requestId: input.requestId, + text: input.text, + }), + }; + + const result = await submitWorkHubSurfaceInput({ + controller, + input: { requestId: 'discussion', text: '这个方向的价值是什么?' }, + }); + + assert.equal(result.kind, 'discussion'); +}); + +test('real Session projection creates new guide topics and preserves origin ambiguity', async () => { + let clock = 10; + const sessions: WorkHubDesktopSession[] = [{ + id: 'login', + name: '刷新令牌过期致重复登录的排查计划', + labels: [], + isArchived: false, + status: 'active', + projectId: 'project-router', + lastMessageAt: clock, + lastMessagePreview: '已经整理为检查清单', + }]; + const prompts = new Map([[ + 'login', + ['排查登录刷新令牌过期导致重复登录的问题,先只分析并列出计划,不修改文件。'], + ]]); + const created: string[] = []; + const port = createDesktopWorkHubSessionPort({ + sessions: { + list: async () => sessions, + listTurns: async (sessionId) => (prompts.get(sessionId) ?? []) + .map((userPromptPreview) => ({ userPromptPreview })), + create: async ({ name }) => { + const id = name.includes('支付回调') ? 'payment' : 'layout'; + const session: WorkHubDesktopSession = { + id, + name: id === 'payment' ? '支付回调幂等性' : '移动端窄屏布局', + labels: [], + isArchived: false, + status: 'active', + projectId: 'project-maka', + lastMessageAt: ++clock, + }; + created.push(id); + sessions.push(session); + prompts.set(id, []); + return session; + }, + send: async (sessionId, command) => { + prompts.get(sessionId)?.push(command.text); + const session = sessions.find((candidate) => candidate.id === sessionId); + if (session) { + session.lastMessageAt = ++clock; + session.lastMessagePreview = command.text; + } + return { ok: true, turnId: command.turnId }; + }, + stop: async () => {}, + subscribeChanges: () => () => {}, + }, + projectName: (projectId) => projectId === 'project-router' + ? 'maka-workhub-session-router' + : 'maka-agent', + newTurnId: () => `turn-${clock + 1}`, + }); + const controller = createWorkHubController({ sessions: port }); + + const payment = await controller.submit({ + requestId: 'setup-payment', + text: '检查支付回调重复投递时的幂等性,先只分析风险和测试点,不修改文件。', + }); + const layout = await controller.submit({ + requestId: 'setup-layout', + text: '优化 WorkHub 在移动端窄屏下的消息布局,先给设计建议,不修改文件。', + }); + await controller.submit({ + requestId: 'focus-login', + text: '刷新令牌过期致重复登录的排查计划:补充观测日志字段。', + }); + const ambiguous = await controller.submit({ + requestId: 'ambiguous-repeat', + text: '继续处理重复问题', + }); + + assert.equal(payment.kind === 'submitted' ? payment.evidence : undefined, 'new_session'); + assert.equal(layout.kind === 'submitted' ? layout.evidence : undefined, 'new_session'); + assert.deepEqual(created, ['payment', 'layout']); + assert.equal(ambiguous.kind, 'clarification'); + assert.deepEqual(ambiguous.kind === 'clarification' + ? ambiguous.options.map((option) => option.target.sessionId) + : [], ['login', 'payment']); +}); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 23b0d49ef5..e8fb03eeed 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -79,6 +79,7 @@ import { } from '@maka/ui'; import type { ConnectionEvent } from '@maka/core/connections'; import { GitBranch, MessageCircleQuestion, Minimize2, Network } from '@maka/ui/icons'; +import { Button } from '@astryxdesign/core/Button'; import { useKeyboardHelp } from './keyboard-help'; import { useCommandPalette } from './command-palette'; import { ChatMessageSurface } from './chat-message-surface'; @@ -128,6 +129,9 @@ import { import { ProviderLogo } from './settings/provider-display'; import { ProviderBrandMark } from './settings/provider-brand-marks'; import { RuntimeHostSshTerminalDialog } from './settings/runtime-host-ssh-terminal-dialog.js'; +import { createWorkHubController } from './workhub-controller.js'; +import { createDesktopWorkHubSessionPort } from './workhub-session-port.js'; +import { WorkHubSurface } from './workhub-surface.js'; import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy'; import { getDesktopConversationCopy } from './locales/conversation-copy'; import { ErrorBoundary } from './error-boundary'; @@ -450,6 +454,35 @@ function AppShellContent({ )); }, []); const navSelectionRef = useRef(navSelection); + const [workHubEnabled, setWorkHubEnabled] = useState(false); + const [workHubActive, setWorkHubActive] = useState(false); + const workHubEnabledRef = useRef(false); + useEffect(() => { + let disposed = false; + const refresh = async () => { + try { + const enabled = (await window.maka.settings.getClient()).workHub.enabled; + if (disposed) return; + const becameEnabled = enabled && !workHubEnabledRef.current; + workHubEnabledRef.current = enabled; + setWorkHubEnabled(enabled); + if (!enabled) setWorkHubActive(false); + if (becameEnabled) { + setWorkHubActive(true); + setNavSelection({ section: 'sessions' }); + } + } catch { + // Keep the last known client-owned setting. A transient settings read + // must not leave the shell half-switched between WorkHub and Session. + } + }; + void refresh(); + const unsubscribe = window.maka.settings.subscribeClientChanged(() => void refresh()); + return () => { + disposed = true; + unsubscribe(); + }; + }, [setNavSelection]); // #1985: the shell's complete read of session UI state. See the hook for why // the two token-rate maps are absent. const { @@ -1215,6 +1248,7 @@ function AppShellContent({ } function openSessionInChat(sessionId: string, turnId?: string, sequence?: number): void { + setWorkHubActive(false); setNavSelection({ section: 'sessions' }); setActiveId(sessionId); if (turnId) { @@ -1269,6 +1303,10 @@ function AppShellContent({ const sessionListSelectSession = useCallback((sessionId: string) => { openSessionInChatRef.current(sessionId); }, []); + const openWorkHub = useCallback(() => { + setNavSelection({ section: 'sessions' }); + setWorkHubActive(true); + }, [setNavSelection]); // PR109f: branched session context. When the active session was // created via `sessions:branchFromTurn`, its `parentSessionId` is @@ -1625,6 +1663,13 @@ function AppShellContent({ }, toastApi, }); + const workHubController = useMemo(() => createWorkHubController({ + sessions: createDesktopWorkHubSessionPort({ + sessions: window.maka.sessions, + projectName: (projectId) => projects.find((project) => project.id === projectId)?.name, + newTurnId: () => crypto.randomUUID(), + }), + }), [projects]); // Where a NEW chat starts. Built unconditionally and handed to the composer, // which renders it only while no session owns it — the project is fixed once // the first message creates one, so there is nothing to pick after that. @@ -1774,7 +1819,7 @@ function AppShellContent({ [toastApi], ); const workbarAvailable = - navSelection.section === 'sessions' && Boolean(activeId); + navSelection.section === 'sessions' && !workHubActive && Boolean(activeId); const workbar = useWorkbarController({ available: workbarAvailable, activeSession: activeSessionForView, @@ -2805,7 +2850,7 @@ function AppShellContent({ summary loads, and the name this replaced (the context layer's) was showing through that window. Hung on the real record alone, 新任务 was named nowhere for the length of it. */} - {navSelection.section === 'sessions' && activeSessionForView && ( + {navSelection.section === 'sessions' && !workHubActive && activeSessionForView && ( { + setWorkHubActive(false); + setNavSelection(selection); + }} onSelectSession={sessionListSelectSession} onOpenSettings={openSettings} buildStamp={buildStamp} updateReminder={updateReminder} onOpenUpdate={openUpdateDownload} - onNew={createSession} + onNew={() => { + setWorkHubActive(false); + void createSession(); + }} + workHubEntry={workHubEnabled ? { + active: workHubActive, + label: 'WorkHub', + onSelect: openWorkHub, + } : undefined} rowActions={sessionRowActions} projectActions={projectRowActions} /> @@ -2967,6 +3023,13 @@ function AppShellContent({ onSaveMarkdown={(input) => saveDailyReviewMarkdown(input, { shouldShowFeedback: isDailyReviewSurfaceActive })} /> ) : null} + {workHubEnabled && workHubActive && navSelection.section === 'sessions' ? ( + + ) : ( ) : null} {navSelection.section === 'sessions' ? : null} + {workHubEnabled && navSelection.section === 'sessions' && activeId ? ( + + ))} + + + ) : turn.outcome?.kind === 'discussion' ? ( + <> +

{copy.discussionStayed}

+ {copy.discussionHint} + + ) : turn.outcome?.kind === 'waiting' ? ( +
+

{copy.waitingForDecision}

+ {copy.requestNotSent} +
+ ) : submitted ? ( + + session.target.sessionId === submitted.correctedFrom?.sessionId, + ) + : undefined} + targetSessionId={submitted.target.sessionId} + copy={copy} + correctionOptions={props.projection.sessions.filter( + (session) => + !session.archived && + session.target.sessionId !== submitted.target.sessionId, + )} + pending={props.pending} + onCorrect={(target) => props.onCorrect(submitted.target, target)} + onOpenSession={props.onOpenSession} + /> + ) : null} + + + + ); +} + +function SubmittedWorkView(props: { + session: WorkHubSessionSummary | undefined; + correctedFrom: WorkHubSessionSummary | undefined; + targetSessionId: string; + copy: ReturnType; + correctionOptions: WorkHubSessionSummary[]; + pending: boolean; + onCorrect(target: { sessionId: string }): void; + onOpenSession(sessionId: string): void; +}) { + const { session, copy } = props; + const state = session ? (session.archived ? copy.archived : copy.states[session.state]) : copy.accepted; + return ( +
+

{copy.sentTo}

+ + {props.correctedFrom ? ( + + {copy.correctedFrom(props.correctedFrom.sessionName)} + + ) : null} + {session?.latestResult ?

{session.latestResult}

: null} +
+ {copy.correctTarget} +
+ {props.correctionOptions.map((option) => ( + + ))} +
+
+
+ ); +} + +function workHubCopy(locale: UiLocale) { + if (locale === 'zh') { + return { + subtitle: '在一个入口里继续、创建和查看普通 Session', + emptyTitle: '从这里继续所有工作', + emptyBody: (count: number) => count > 0 + ? `WorkHub 会根据已有 ${count} 个 Session 判断目标;不确定时会先询问你。` + : '提出一个明确目标,WorkHub 会创建普通 Session 并把结果带回这里。', + workCount: (count: number) => `${count} 项工作`, clarification: '选择工作', + chooseWork: '这条输入可能与多项工作有关,请选择目标:', + discussionStayed: '这条内容暂时保留在 WorkHub,没有创建或改动 Session。', + discussionHint: '提出明确的执行目标后,我会把它交给对应的 Session。', + sentTo: '已交给:', accepted: '已接收', sessionFallback: '普通 Session', + correctTarget: '更正目标', + correctedFrom: (name: string) => `已从“${name}”更正`, + waitingForDecision: '这项工作正在等待你的决定。', + requestNotSent: '新请求尚未发送;处理原 Session 中的交互后可以再次发送。', + routing: '正在判断应该交给哪个 Session…', loadFailed: '无法读取已有工作。', + submitFailed: '输入未能送达,请重试。', scrollToBottom: '滚动到底部', archived: '已归档', + states: { active: '活跃', running: '进行中', waiting_for_user: '等待你', blocked: '受阻', aborted: '已中止' }, + } as const; + } + return { + subtitle: 'Continue, create, and review ordinary Sessions from one place', + emptyTitle: 'Continue all work from here', + emptyBody: (count: number) => count > 0 + ? `WorkHub routes against ${count} existing Session${count === 1 ? '' : 's'} and asks when the target is unclear.` + : 'State a clear goal and WorkHub will create an ordinary Session and bring its result back here.', + workCount: (count: number) => `${count} work item${count === 1 ? '' : 's'}`, clarification: 'Choose work', + chooseWork: 'This input may relate to more than one task. Choose a target:', + discussionStayed: 'This stayed in WorkHub without creating or changing a Session.', + discussionHint: 'State an executable goal and I will hand it to the owning Session.', + sentTo: 'Sent to:', accepted: 'Accepted', sessionFallback: 'Ordinary Session', + correctTarget: 'Correct target', + correctedFrom: (name: string) => `Corrected from “${name}”`, + waitingForDecision: 'This work is waiting for your decision.', + requestNotSent: 'The new request was not sent. Resolve the interaction in its Session, then send again.', + routing: 'Choosing the right Session…', loadFailed: 'Could not read existing work.', + submitFailed: 'The input could not be delivered. Try again.', scrollToBottom: 'Scroll to bottom', archived: 'Archived', + states: { active: 'Active', running: 'Running', waiting_for_user: 'Waiting for you', blocked: 'Blocked', aborted: 'Aborted' }, + } as const; +} diff --git a/apps/desktop/src/shared/settings-ownership.ts b/apps/desktop/src/shared/settings-ownership.ts index 71e76efbf5..1447e03178 100644 --- a/apps/desktop/src/shared/settings-ownership.ts +++ b/apps/desktop/src/shared/settings-ownership.ts @@ -55,6 +55,7 @@ export function clientOwnedSettingsPatch( ...(appearance ? { appearance } : {}), ...(personalization ? { personalization } : {}), ...(patch.notifications ? { notifications: patch.notifications } : {}), + ...(patch.workHub ? { workHub: patch.workHub } : {}), ...(patch.projects ? { projects: patch.projects } : {}), ...(patch.system ? { system: patch.system } : {}), }; @@ -95,6 +96,7 @@ export function projectClientOwnedSettings( onboarding: client.onboarding, projects: client.projects, notifications: client.notifications, + workHub: client.workHub, system: client.system, }; } diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index f1223a3c87..8569a8d979 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -5,7 +5,7 @@ Each row is one on-disk product surface file. Regenerated inventory must stay in Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 202 files — blocker 0, polish 0, aligned 202. +**Totals:** 204 files — blocker 0, polish 0, aligned 204. ## Exclusions (explicit) @@ -30,7 +30,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/app-shell-chrome-actions.tsx` | shell-chrome-or-panel | IconButton, Tooltip | aligned — uses Astryx (IconButton, Tooltip) | aligned | | `apps/desktop/src/renderer/app-shell-detail-panel.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/app-shell-overlays.tsx` | shell-chrome-or-panel | Spinner | aligned — uses Astryx (Spinner) | aligned | -| `apps/desktop/src/renderer/app-shell.tsx` | shell-chrome-or-panel | AppShell | aligned — uses Astryx (AppShell) | aligned | +| `apps/desktop/src/renderer/app-shell.tsx` | shell-chrome-or-panel | AppShell, Button | aligned — uses Astryx (AppShell, Button) | aligned | | `apps/desktop/src/renderer/app.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/cascade-layers.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/chat-composer-region.tsx` | shell-chrome-or-panel | Banner, Button | aligned — uses Astryx (Banner, Button) | aligned | @@ -171,6 +171,8 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/styles/workbar/shell.css` | shell-chrome-or-panel | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/styles/workbar/side-chat.css` | shell-chrome-or-panel | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/styles/workbar/terminal.css` | shell-chrome-or-panel | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | +| `apps/desktop/src/renderer/styles/workhub.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | +| `apps/desktop/src/renderer/workhub-surface.tsx` | other | Button | aligned — uses Astryx (Button) | aligned | | `packages/ui/src/astryx-chat-reasoning.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/astryx-i18n.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/attachment-kinds.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index c26bb527e5..e612562a0a 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -143,6 +143,8 @@ apps/desktop/src/renderer/styles/workbar/review.css apps/desktop/src/renderer/styles/workbar/shell.css apps/desktop/src/renderer/styles/workbar/side-chat.css apps/desktop/src/renderer/styles/workbar/terminal.css +apps/desktop/src/renderer/styles/workhub.css +apps/desktop/src/renderer/workhub-surface.tsx packages/ui/src/astryx-chat-reasoning.tsx packages/ui/src/astryx-i18n.tsx packages/ui/src/attachment-kinds.tsx diff --git a/docs/workhub-domain-language.md b/docs/workhub-domain-language.md new file mode 100644 index 0000000000..7febaa2820 --- /dev/null +++ b/docs/workhub-domain-language.md @@ -0,0 +1,17 @@ +# WorkHub domain language + +WorkHub gives users one conversational place to continue, create, and inspect work while ordinary Sessions remain the product's canonical work records. + +## Terms + +**Session**: The authoritative record for identity, transcript, execution state, permissions, interactions, and recovery. A Session ID is the stable identity of the work. + +**Work**: The user-facing continuity of exactly one ordinary Session. “Work” is a product-language view of a Session, not a second stored record. + +**WorkHub**: A projection and routing surface over ordinary Sessions. It may keep transient inference context while mounted, but it does not own a transcript or execution state. + +**Session projection**: A rebuildable view derived from Session facts for display and routing. It can be discarded and recreated without losing work. + +**Route correction**: A user's decision that an input belongs to a different existing Session. It may influence later transient routing, but it does not become an authority for Session content or state. + +_Avoid_: independent Work records, copied transcripts, or a second writable WorkHub state store. diff --git a/packages/core/src/__tests__/settings.test.ts b/packages/core/src/__tests__/settings.test.ts index c2424e53b4..2cc040cd63 100644 --- a/packages/core/src/__tests__/settings.test.ts +++ b/packages/core/src/__tests__/settings.test.ts @@ -173,3 +173,13 @@ test('an app icon that never passed normalization still coerces to the brand mar expect(toAppIconChoice('sky')).toBe('sky'); expect(toAppIconChoice(`custom:${'a'.repeat(32)}`)).toBe(`custom:${'a'.repeat(32)}`); }); + +test('WorkHub stays opt-in and malformed persisted values fail closed', () => { + const defaults = createDefaultSettings(); + expect(defaults.workHub).toEqual({ enabled: false }); + expect(normalizeSettings({ workHub: { enabled: true } }).workHub).toEqual({ enabled: true }); + expect(normalizeSettings({ workHub: { enabled: 'yes' } }).workHub).toEqual({ enabled: false }); + expect(mergeSettings(defaults, { workHub: { enabled: true } }).workHub).toEqual({ + enabled: true, + }); +}); diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index 1c8e7dc53f..e3467dad00 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -326,6 +326,11 @@ export interface NotificationSettings { runComplete: boolean; } +/** Client-owned opt-in for the cross-Session WorkHub router. */ +export interface WorkHubSettings { + enabled: boolean; +} + /** * System-level power behavior (Settings surface: the 定时任务 page's * capability row). Scheduled tasks are driven by an in-process timer; when @@ -367,6 +372,7 @@ export interface AppSettings { chatDefaults: ChatDefaultsSettings; projects: ProjectPreferencesSettings; notifications: NotificationSettings; + workHub: WorkHubSettings; system: SystemSettings; shell: ShellSettings; subagents: SubagentSettings; @@ -470,6 +476,7 @@ export type UpdateAppSettingsInput = Partial<{ chatDefaults: Partial; projects: Partial; notifications: Partial; + workHub: Partial; system: Partial; shell: Partial; webSearch: WebSearchSettingsPatch; @@ -548,6 +555,9 @@ export function createDefaultSettings(): AppSettings { notifications: { runComplete: true, }, + workHub: { + enabled: false, + }, system: { // Off by default: holding a power-save blocker is an explicit, // battery-affecting opt-in, not a silent default. @@ -624,6 +634,10 @@ export function mergeSettings(current: AppSettings, patch: UpdateAppSettingsInpu ...current.notifications, ...(patch.notifications ?? {}), }, + workHub: { + ...current.workHub, + ...(patch.workHub ?? {}), + }, system: { ...current.system, ...(patch.system ?? {}), @@ -657,6 +671,7 @@ export function normalizeSettings(input: unknown): AppSettings { chatDefaults: value.chatDefaults, projects: value.projects, notifications: value.notifications, + workHub: value.workHub, system: value.system, shell: value.shell, subagents: value.subagents, @@ -735,6 +750,9 @@ export function normalizeSettings(input: unknown): AppSettings { runComplete: typeof base.notifications.runComplete === 'boolean' ? base.notifications.runComplete : true, }, + workHub: { + enabled: typeof base.workHub.enabled === 'boolean' ? base.workHub.enabled : false, + }, // Fail-closed boolean coercion, same reasoning as // `notifications.runComplete`: a non-boolean `keepSystemAwake` (from a // hand-edited or legacy settings.json) must not reach the main-process diff --git a/packages/ui/src/session-list-panel.tsx b/packages/ui/src/session-list-panel.tsx index 332c26c701..8c9fb8cb12 100644 --- a/packages/ui/src/session-list-panel.tsx +++ b/packages/ui/src/session-list-panel.tsx @@ -77,6 +77,11 @@ export function SessionListPanel(props: { updateReminder?: SidebarUpdateReminder; onOpenUpdate?(): void; onNew(): void; + workHubEntry?: { + active: boolean; + label: string; + onSelect(): void; + }; rowActions?: SessionRowActions; }) { const copy = getConversationCopy(useUiLocale()).sessions; @@ -170,6 +175,7 @@ export function SessionListPanel(props: { moduleMemory={props.moduleMemory} onSelect={props.onSelect} onNew={props.onNew} + workHubEntry={props.workHubEntry} /> {groupingSwitch} diff --git a/packages/ui/src/session-sidebar-nav.tsx b/packages/ui/src/session-sidebar-nav.tsx index e0d5e97459..4df03e1be0 100644 --- a/packages/ui/src/session-sidebar-nav.tsx +++ b/packages/ui/src/session-sidebar-nav.tsx @@ -18,7 +18,7 @@ */ import type { ScheduledTask } from '@maka/core/scheduled-task'; -import { AlertCircle, Blocks, Download, Settings, SquarePen, Timer } from './icons.js'; +import { AlertCircle, Blocks, Download, Network, Settings, SquarePen, Timer } from './icons.js'; import type { NavModuleMemory, NavSelection } from './nav-selection.js'; import { useUiLocale } from './locale-context.js'; import { getShellControlsCopy } from './shell-controls-copy.js'; @@ -33,6 +33,11 @@ export function SessionSidebarNav(props: { moduleMemory?: NavModuleMemory; onSelect(selection: NavSelection): void; onNew(): void; + workHubEntry?: { + active: boolean; + label: string; + onSelect(): void; + }; }) { const locale = useUiLocale(); const copy = getShellControlsCopy(locale).navigation; @@ -63,6 +68,15 @@ export function SessionSidebarNav(props: { onClick={props.onNew} endContent={} /> + {props.workHubEntry ? ( + + ) : null} {/* No 任务 row. Expanded, the list below IS that row's destination, and a control that selects what is already on screen under it is the same redundancy as the 会话 list heading this change deleted one row down. From 68f6b7c52528d11e336697c59403e82eba0626c8 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sun, 23 Aug 2026 03:40:34 +0800 Subject: [PATCH 2/6] chore: add ASF headers to WorkHub sources Generated-by: Codex --- .../main/__tests__/workhub-controller.test.ts | 19 +++++++++++++++++++ .../__tests__/workhub-session-port.test.ts | 19 +++++++++++++++++++ .../workhub-settings-ownership.test.ts | 19 +++++++++++++++++++ .../__tests__/workhub-surface-flow.test.ts | 19 +++++++++++++++++++ apps/desktop/src/renderer/styles/workhub.css | 19 +++++++++++++++++++ .../src/renderer/workhub-controller.ts | 19 +++++++++++++++++++ .../src/renderer/workhub-route-policy.ts | 19 +++++++++++++++++++ .../src/renderer/workhub-session-port.ts | 19 +++++++++++++++++++ apps/desktop/src/renderer/workhub-surface.tsx | 19 +++++++++++++++++++ docs/workhub-domain-language.md | 19 +++++++++++++++++++ 10 files changed, 190 insertions(+) diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 032a1ceffe..f7385c1f29 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -1,3 +1,22 @@ +/* + * 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 test from 'node:test'; import { diff --git a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts index d5d96f06dd..c86402ac10 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -1,3 +1,22 @@ +/* + * 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 test from 'node:test'; import { diff --git a/apps/desktop/src/main/__tests__/workhub-settings-ownership.test.ts b/apps/desktop/src/main/__tests__/workhub-settings-ownership.test.ts index 226d9e251a..8a68fdae1c 100644 --- a/apps/desktop/src/main/__tests__/workhub-settings-ownership.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-settings-ownership.test.ts @@ -1,3 +1,22 @@ +/* + * 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 test from 'node:test'; import { createDefaultSettings } from '@maka/core/settings'; diff --git a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts index 45417f1447..3b538712f3 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -1,3 +1,22 @@ +/* + * 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 test from 'node:test'; import { diff --git a/apps/desktop/src/renderer/styles/workhub.css b/apps/desktop/src/renderer/styles/workhub.css index 12b228f270..0aadc342ae 100644 --- a/apps/desktop/src/renderer/styles/workhub.css +++ b/apps/desktop/src/renderer/styles/workhub.css @@ -1,3 +1,22 @@ +/* + * 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. + */ + /* WorkHub is a conversation surface. These rules intentionally reuse the product chat layout instead of maintaining a second page/composer system. */ .workhub-surface { diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index 1a80269c58..a8bdbadb19 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -1,3 +1,22 @@ +/* + * 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. + */ + /** * WorkHub is a projection and routing surface over ordinary Sessions. * Session and Runtime remain authoritative for transcript, execution, state, diff --git a/apps/desktop/src/renderer/workhub-route-policy.ts b/apps/desktop/src/renderer/workhub-route-policy.ts index 4c94f6e6e5..6ebeca76a9 100644 --- a/apps/desktop/src/renderer/workhub-route-policy.ts +++ b/apps/desktop/src/renderer/workhub-route-policy.ts @@ -1,3 +1,22 @@ +/* + * 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 type { WorkHubSessionFacts, WorkHubSessionTarget, diff --git a/apps/desktop/src/renderer/workhub-session-port.ts b/apps/desktop/src/renderer/workhub-session-port.ts index fb639a8990..347784cb71 100644 --- a/apps/desktop/src/renderer/workhub-session-port.ts +++ b/apps/desktop/src/renderer/workhub-session-port.ts @@ -1,3 +1,22 @@ +/* + * 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 type { WorkHubSessionFacts, WorkHubSessionPort, diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index c58fab7344..368b2dda07 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -1,3 +1,22 @@ +/* + * 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 { useCallback, useEffect, useRef, useState } from 'react'; import { ChatMessage, ChatMessageBubble, ChatMessageList } from '@astryxdesign/core'; import { Button } from '@astryxdesign/core/Button'; diff --git a/docs/workhub-domain-language.md b/docs/workhub-domain-language.md index 7febaa2820..3d0231fd80 100644 --- a/docs/workhub-domain-language.md +++ b/docs/workhub-domain-language.md @@ -1,3 +1,22 @@ + + # WorkHub domain language WorkHub gives users one conversational place to continue, create, and inspect work while ordinary Sessions remain the product's canonical work records. From 4a67b3ee6694dd390d4048a2859d7006857073a9 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sun, 23 Aug 2026 15:00:54 +0800 Subject: [PATCH 3/6] fix: harden WorkHub routing corrections Generated-by: Codex --- .../permission-response-ipc-boundary.test.ts | 15 +++- ...me-host-session-execution-ipc-main.test.ts | 10 ++- .../main/__tests__/workhub-controller.test.ts | 73 ++++++++++++++++++- .../__tests__/workhub-session-port.test.ts | 64 +++++++++++++++- .../__tests__/workhub-surface-flow.test.ts | 15 ++++ .../src/main/permission-response-guard.ts | 20 ++++- ...runtime-host-session-execution-ipc-main.ts | 37 ++++++++-- apps/desktop/src/preload/bridge-contract.d.ts | 5 +- apps/desktop/src/preload/preload.ts | 5 +- .../src/renderer/workhub-controller.ts | 19 +++-- .../src/renderer/workhub-route-policy.ts | 40 +++++++--- .../src/renderer/workhub-session-port.ts | 21 ++++-- apps/desktop/src/renderer/workhub-surface.tsx | 71 +++++++++++------- 13 files changed, 327 insertions(+), 68 deletions(-) diff --git a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts index d2d1c38c8c..bbc6bbad36 100644 --- a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts @@ -247,10 +247,19 @@ describe('permission response IPC boundary', () => { it('accepts only the supported stop source', () => { assert.deepEqual(normalizeStopSessionInput(undefined), {}); - assert.deepEqual(normalizeStopSessionInput({ source: 'stop_button', extra: true }), { - source: 'stop_button', - }); + assert.deepEqual( + normalizeStopSessionInput({ + source: 'stop_button', + expectedTurnId: 'turn-workhub', + extra: true, + }), + { source: 'stop_button', expectedTurnId: 'turn-workhub' }, + ); assert.throws(() => normalizeStopSessionInput(null), /stop session input/); assert.throws(() => normalizeStopSessionInput({ source: 'toolbar' }), /stop session source/); + assert.throws( + () => normalizeStopSessionInput({ expectedTurnId: '' }), + /expectedTurnId/, + ); }); }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index 2a3574cbe2..4d30d0e707 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -883,7 +883,15 @@ test("binds steer and stop to Host-owned queue and active Turn identities", asyn kind: "queued", }, ); - await ipc.invoke("sessions:stop", "session-1"); + await ipc.invoke("sessions:stop", "session-1", { + source: "stop_button", + expectedTurnId: "turn-unrelated", + }); + assert.deepEqual(stopLifecycle, []); + await ipc.invoke("sessions:stop", "session-1", { + source: "stop_button", + expectedTurnId: "turn-1", + }); assert.deepEqual(stopLifecycle, ["teardown", "interrupt"]); assert.deepEqual(submits, [ diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index f7385c1f29..9337743660 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -218,6 +218,32 @@ test('a unique longer Session name outranks a generic contained Session name', a assert.deepEqual(submitted, ['layout']); }); +test('a short Latin Session name does not match inside another word', async () => { + const submitted: string[] = []; + const created: string[] = []; + const sessions = port([ + session('ai', { sessionName: 'AI' }), + ]); + sessions.create = async ({ name }) => { + created.push(name); + return session('parser-new', { sessionName: name }); + }; + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: 'turn-parser' }; + }; + + const result = await createWorkHubController({ sessions }).submit({ + requestId: 'request-parser', + text: '修复 repair parser 的错误', + }); + + assert.equal(result.kind, 'submitted'); + assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); + assert.deepEqual(created, ['修复 repair parser 的错误']); + assert.deepEqual(submitted, ['parser-new']); +}); + test('submit asks the user when weak relevance matches more than one Session', async () => { const submitted: string[] = []; const sessions = port([ @@ -645,7 +671,7 @@ test('route correction stops the wrong Session and teaches a similar request', a requestId: 'request-alias', text: '继续白鹭点,列出验收项。', explicitTarget: { sessionId: 'login' }, - correction: { from: { sessionId: 'payment' } }, + correction: { from: { sessionId: 'payment' }, turnId: 'turn-2' }, }); assert.equal(corrected.kind, 'submitted'); assert.equal(corrected.kind === 'submitted' ? corrected.evidence : undefined, 'route_correction'); @@ -665,6 +691,47 @@ test('route correction stops the wrong Session and teaches a similar request', a assert.deepEqual(submitted, ['payment', 'payment', 'login', 'login']); }); +test('route correction never stops a root Turn that WorkHub only steered into', async () => { + const stopped: string[] = []; + let submissionCount = 0; + const sessions = port([ + session('login', { sessionName: '登录稳定性' }), + session('payment', { sessionName: '支付稳定性', state: 'running' }), + ]); + sessions.submit = async () => { + submissionCount += 1; + return submissionCount === 1 + ? { turnId: 'turn-existing', steered: true } + : { turnId: 'turn-login' }; + }; + sessions.stop = async (target) => { + stopped.push(target.sessionId); + }; + const controller = createWorkHubController({ sessions }); + + const wrong = await controller.submit({ + requestId: 'request-steered', + text: '继续补充支付验收项', + explicitTarget: { sessionId: 'payment' }, + }); + assert.equal(wrong.kind === 'submitted' ? wrong.steered : undefined, true); + + const correction = { + from: { sessionId: 'payment' }, + turnId: 'turn-existing', + steered: true as const, + }; + const corrected = await controller.submit({ + requestId: 'request-steered', + text: '不是支付,应该补充登录验收项', + explicitTarget: { sessionId: 'login' }, + correction, + }); + + assert.equal(corrected.kind, 'submitted'); + assert.deepEqual(stopped, []); +}); + test('latest route correction wins for the same expression family', async () => { const sessions = port([ session('login', { sessionName: '登录稳定性' }), @@ -677,13 +744,13 @@ test('latest route correction wins for the same expression family', async () => requestId: 'correction-login', text: '继续白鹭点,列出验收项。', explicitTarget: { sessionId: 'login' }, - correction: { from: { sessionId: 'payment' } }, + correction: { from: { sessionId: 'payment' }, turnId: 'turn' }, }); await controller.submit({ requestId: 'correction-payment', text: '继续白鹭点,列出异常项。', explicitTarget: { sessionId: 'payment' }, - correction: { from: { sessionId: 'login' } }, + correction: { from: { sessionId: 'login' }, turnId: 'turn' }, }); const result = await controller.submit({ diff --git a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts index c86402ac10..bb31829e9c 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -127,7 +127,10 @@ test('desktop adapter delegates create, send, and invalidation to Session APIs', let onChanged: (() => void) | undefined; const adapter = createDesktopWorkHubSessionPort({ sessions: { - list: async () => [], + list: async () => [desktopSession('created', { + status: 'running', + runningTurnIds: ['turn-new'], + })], listTurns: async () => [], create: async (input) => { calls.push(['create', input]); @@ -151,7 +154,7 @@ test('desktop adapter delegates create, send, and invalidation to Session APIs', const created = await adapter.create({ name: '实现导出发票 PDF 功能' }); const turn = await adapter.submit(created.target, '实现导出发票 PDF 功能'); - await adapter.stop(created.target); + await adapter.stop(created.target, 'turn-new'); let invalidations = 0; const unsubscribe = adapter.subscribe(() => { invalidations += 1; @@ -165,11 +168,66 @@ test('desktop adapter delegates create, send, and invalidation to Session APIs', assert.deepEqual(calls, [ ['create', { name: '实现导出发票 PDF 功能' }], ['send', 'created', { type: 'send', turnId: 'turn-new', text: '实现导出发票 PDF 功能' }], - ['stop', 'created', { source: 'stop_button' }], + ['stop', 'created', { source: 'stop_button', expectedTurnId: 'turn-new' }], ['unsubscribe'], ]); }); +test('desktop adapter preserves when Session delivery steered an existing root Turn', async () => { + const adapter = createDesktopWorkHubSessionPort({ + sessions: { + list: async () => [], + listTurns: async () => [], + create: async () => { + throw new Error('not used'); + }, + send: async (_sessionId, command) => ({ + ok: true, + turnId: command.turnId, + steered: true, + }), + stop: async () => {}, + subscribeChanges: () => () => {}, + }, + projectName: () => 'Maka', + newTurnId: () => 'turn-steered', + }); + + assert.deepEqual( + await adapter.submit({ sessionId: 'busy' }, '补充已有执行流'), + { turnId: 'turn-steered', steered: true }, + ); +}); + +test('desktop adapter binds stop to the root Turn owned by the WorkHub submission', async () => { + const stopped: unknown[] = []; + const adapter = createDesktopWorkHubSessionPort({ + sessions: { + list: async () => [], + listTurns: async () => [], + create: async () => { + throw new Error('not used'); + }, + send: async () => { + throw new Error('not used'); + }, + stop: async (sessionId, input) => { + stopped.push([sessionId, input]); + }, + subscribeChanges: () => () => {}, + }, + projectName: () => 'Maka', + newTurnId: () => 'unused', + }); + + await adapter.stop({ sessionId: 'payment' }, 'turn-workhub'); + + assert.deepEqual(stopped, [[ + 'payment', + { source: 'stop_button', expectedTurnId: 'turn-workhub' }, + ]]); +}); + test('desktop adapter derives stable origin evidence from the existing Session log', async () => { let reads = 0; const adapter = createDesktopWorkHubSessionPort({ diff --git a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts index 3b538712f3..f58d44c029 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -22,6 +22,7 @@ import test from 'node:test'; import { WorkHubSurfaceRouteGate, submitWorkHubSurfaceInput, + workHubSubmissionCanCorrect, workHubSubmissionClearsDraft, } from '../../renderer/workhub-surface.js'; import { @@ -69,6 +70,20 @@ test('surface keeps the Composer draft when routing fails or the target is waiti }), true); }); +test('surface disables correction after a request was steered into existing work', () => { + const submission = { + kind: 'submitted' as const, + strategyId: 'wh-r2.3-session-core-evidence' as const, + requestId: 'steered', + target: { sessionId: 'payment' }, + turnId: 'turn-existing', + evidence: 'explicit_target' as const, + }; + + assert.equal(workHubSubmissionCanCorrect(submission), true); + assert.equal(workHubSubmissionCanCorrect({ ...submission, steered: true }), false); +}); + test('surface keeps clarification and successful routing in WorkHub', async () => { const submissions: WorkHubSubmitInput[] = []; const controller: WorkHubController = { diff --git a/apps/desktop/src/main/permission-response-guard.ts b/apps/desktop/src/main/permission-response-guard.ts index 03597bc010..1dd307674a 100644 --- a/apps/desktop/src/main/permission-response-guard.ts +++ b/apps/desktop/src/main/permission-response-guard.ts @@ -62,7 +62,10 @@ interface NormalizedSendSessionCommand { quotes?: QuoteRef[]; workspaceFileReferences?: WorkspaceFileReferencePosition[]; } -type NormalizedStopSessionInput = { source?: 'stop_button' }; +type NormalizedStopSessionInput = { + source?: 'stop_button'; + expectedTurnId?: string; +}; export function normalizeSandboxBoundaryResponse(input: unknown): SandboxBoundaryResponse { if (!input || typeof input !== 'object') { @@ -311,11 +314,20 @@ export function normalizeSessionSkillIds(input: unknown): string[] { export function normalizeStopSessionInput(input: unknown): NormalizedStopSessionInput { if (input === undefined) return {}; const value = requireObject(input, 'Invalid stop session input'); - if (value.source === undefined) return {}; - if (value.source !== 'stop_button') { + if (value.source !== undefined && value.source !== 'stop_button') { throw new Error('Invalid stop session source'); } - return { source: 'stop_button' }; + const expectedTurnId = value.expectedTurnId === undefined + ? undefined + : normalizeRequiredString( + value.expectedTurnId, + 'Invalid stop session expectedTurnId', + MAX_TURN_ID_LENGTH, + ); + return { + ...(value.source ? { source: 'stop_button' as const } : {}), + ...(expectedTurnId ? { expectedTurnId } : {}), + }; } function requireObject(input: unknown, errorMessage: string): Record { diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index 3c8a09f77e..2feb234fcc 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -41,6 +41,7 @@ import { normalizeRuntimeHostReviseBeforeTurnInput, normalizeSandboxBoundaryResponse, normalizeSessionSendCommand, + normalizeStopSessionInput, normalizeUserQuestionResponse, } from "./permission-response-guard.js"; import { @@ -72,6 +73,7 @@ type RuntimeHostSessionExecutionClient = Pick< | "queryTurnResume" | "readExecutionBoundary" | "regenerateTurn" + | "retractQueue" | "retractQueueEntry" | "promoteQueueEntry" | "reorderQueueEntries" @@ -482,8 +484,19 @@ export function registerRuntimeHostSessionExecutionIpc( }); }, ); - ipcMain.handle("sessions:stop", async (_event, sessionId: string) => - stopSession(sessionId), + ipcMain.handle("sessions:retractQueue", async (_event, sessionId: string) => { + const result = await deps.client.retractQueue({ + sessionId, + retractId: newId(), + }); + return aggregateMessageContents(result.retracted.map((entry) => entry.content)); + }); + ipcMain.handle( + "sessions:stop", + async (_event, sessionId: string, input: unknown) => { + const normalized = normalizeStopSessionInput(input); + return stopSession(sessionId, normalized.expectedTurnId); + }, ); ipcMain.handle( @@ -694,11 +707,25 @@ function createRuntimeHostSessionStop( "beforeStop" | "client" | "observer" | "emitSessionsChanged" >, newId: () => string = randomUUID, -): (sessionId: string) => Promise { - return async (sessionId) => { +): (sessionId: string, expectedTurnId?: string) => Promise { + return async (sessionId, expectedTurnId) => { + if (expectedTurnId) { + const observed = (await deps.observer.snapshot(sessionId)).rootTurn; + if ( + !observed || + isTerminalStatus(observed.status) || + observed.turnId !== expectedTurnId + ) { + return; + } + } await deps.beforeStop(sessionId); const turn = (await deps.observer.snapshot(sessionId)).rootTurn; - if (!turn || isTerminalStatus(turn.status)) return; + if ( + !turn || + isTerminalStatus(turn.status) || + (expectedTurnId && turn.turnId !== expectedTurnId) + ) return; await deps.client.interruptTurn({ sessionId, interruptId: newId(), diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 337a41e2b3..ae0bac729b 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -710,7 +710,10 @@ export interface MakaBridge { skillInvocation: import('@maka/runtime/skill-invocation').SkillInvocationResult; } >; - stop(sessionId: string, input?: { source?: 'stop_button' }): Promise; + stop( + sessionId: string, + input?: { source?: 'stop_button'; expectedTurnId?: string }, + ): Promise; steer(sessionId: string, text: string): Promise; enqueue( sessionId: string, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 8a05f47e3b..b572fd8035 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1500,7 +1500,10 @@ const makaBridge = { > { return invokeSessionRuntimeHost('sessions:resumeLatest', sessionId); }, - stop(sessionId: string, input?: { source?: 'stop_button' }): Promise { + stop( + sessionId: string, + input?: { source?: 'stop_button'; expectedTurnId?: string }, + ): Promise { return invokeSessionRuntimeHost('sessions:stop', sessionId, input); }, steer(sessionId: string, text: string): Promise { diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index a8bdbadb19..781410f710 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -61,7 +61,11 @@ export interface WorkHubSubmitInput { requestId: string; text: string; explicitTarget?: WorkHubSessionTarget; - correction?: { from: WorkHubSessionTarget }; + correction?: { + from: WorkHubSessionTarget; + turnId: string; + steered?: true; + }; } export const WORKHUB_ROUTING_STRATEGY_ID = 'wh-r2.3-session-core-evidence' as const; @@ -73,6 +77,7 @@ export type WorkHubSubmission = ( requestId: string; target: WorkHubSessionTarget; turnId: string; + steered?: true; evidence: WorkHubRouteEvidence | 'new_session'; correctedFrom?: WorkHubSessionTarget; } @@ -109,8 +114,11 @@ export interface WorkHubSessionPort { targets: readonly WorkHubSessionTarget[], ): Promise>; create(input: { name: string }): Promise; - submit(target: WorkHubSessionTarget, text: string): Promise<{ turnId: string }>; - stop(target: WorkHubSessionTarget): Promise; + submit( + target: WorkHubSessionTarget, + text: string, + ): Promise<{ turnId: string; steered?: true }>; + stop(target: WorkHubSessionTarget, expectedTurnId: string): Promise; subscribe(handler: () => void): () => void; } @@ -203,8 +211,8 @@ export function createWorkHubController(deps: { target, }; } - if (input.correction) { - await deps.sessions.stop(input.correction.from); + if (input.correction && !input.correction.steered) { + await deps.sessions.stop(input.correction.from, input.correction.turnId); } const turn = await deps.sessions.submit(target, input.text); routePolicy.rememberTarget(target); @@ -215,6 +223,7 @@ export function createWorkHubController(deps: { requestId: input.requestId, target, turnId: turn.turnId, + ...(turn.steered ? { steered: true as const } : {}), evidence, ...(input.correction ? { correctedFrom: input.correction.from } : {}), }; diff --git a/apps/desktop/src/renderer/workhub-route-policy.ts b/apps/desktop/src/renderer/workhub-route-policy.ts index 6ebeca76a9..e0f671644c 100644 --- a/apps/desktop/src/renderer/workhub-route-policy.ts +++ b/apps/desktop/src/renderer/workhub-route-policy.ts @@ -109,19 +109,14 @@ export function createWorkHubRoutePolicy(): WorkHubRoutePolicy { return { kind: 'new_session' }; } - const normalizedInput = normalizeIdentityText(text); const exact = sessions.map((session) => { - const sessionName = normalizeIdentityText(session.sessionName); - const qualifiedName = normalizeIdentityText( - `${session.projectName}/${session.sessionName}`, - ); + const qualifiedName = `${session.projectName}/${session.sessionName}`; return { session, - matchLength: normalizedInput.includes(qualifiedName) - ? qualifiedName.length - : normalizedInput.includes(sessionName) - ? sessionName.length - : 0, + matchLength: Math.max( + exactIdentityMatchLength(text, qualifiedName), + exactIdentityMatchLength(text, session.sessionName), + ), }; }).filter(({ matchLength }) => matchLength >= MIN_EXACT_SESSION_NAME_LENGTH) .sort((left, right) => right.matchLength - left.matchLength); @@ -235,6 +230,31 @@ function normalizeIdentityText(value: string): string { return value.toLocaleLowerCase().replace(/[\s\p{P}\p{S}]+/gu, ''); } +function exactIdentityMatchLength(input: string, identity: string): number { + const normalizedIdentity = normalizeIdentityText(identity); + if (normalizedIdentity.length < MIN_EXACT_SESSION_NAME_LENGTH) return 0; + + // Latin Session names are matched as complete token sequences. Compacting + // punctuation is useful for Han names, but it must never make a short name + // such as "AI" match inside an unrelated word such as "repair". + if (/^[a-z0-9\s\p{P}\p{S}]+$/iu.test(identity)) { + const inputTokens = latinTokens(input); + const identityTokens = latinTokens(identity); + if ( + identityTokens.length === 0 || + identityTokens.length > inputTokens.length + ) return 0; + const matches = inputTokens.some((_, start) => + identityTokens.every((token, offset) => inputTokens[start + offset] === token) + ); + return matches ? normalizedIdentity.length : 0; + } + + return normalizeIdentityText(input).includes(normalizedIdentity) + ? normalizedIdentity.length + : 0; +} + function looksLikeRecentFocus(value: string): boolean { return /(?:它|这个(?:问题|工作|任务)?|这项(?:工作|任务)|刚才(?:那个|的)?|继续|接着|\bit\b|continue)/iu.test( value, diff --git a/apps/desktop/src/renderer/workhub-session-port.ts b/apps/desktop/src/renderer/workhub-session-port.ts index 347784cb71..2a1218524c 100644 --- a/apps/desktop/src/renderer/workhub-session-port.ts +++ b/apps/desktop/src/renderer/workhub-session-port.ts @@ -46,8 +46,13 @@ export interface WorkHubDesktopSessionBridge { send( sessionId: string, command: { type: 'send'; turnId: string; text: string }, - ): Promise<{ ok: true; turnId: string } | { ok: false; reason: string }>; - stop(sessionId: string, input?: { source?: 'stop_button' }): Promise; + ): Promise< + { ok: true; turnId: string; steered?: true } | { ok: false; reason: string } + >; + stop( + sessionId: string, + input?: { source?: 'stop_button'; expectedTurnId?: string }, + ): Promise; subscribeChanges(handler: () => void): () => void; } @@ -117,10 +122,16 @@ export function createDesktopWorkHubSessionPort(deps: { text, }); if (!result.ok) throw new Error(`WorkHub Session send failed: ${result.reason}`); - return { turnId: result.turnId }; + return { + turnId: result.turnId, + ...(result.steered ? { steered: true as const } : {}), + }; }, - async stop(target) { - await deps.sessions.stop(target.sessionId, { source: 'stop_button' }); + async stop(target, expectedTurnId) { + await deps.sessions.stop(target.sessionId, { + source: 'stop_button', + expectedTurnId, + }); }, subscribe(handler) { return deps.sessions.subscribeChanges(handler); diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 368b2dda07..d569784c7d 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -61,6 +61,12 @@ export function workHubSubmissionClearsDraft( return Boolean(result && result.kind !== 'waiting'); } +export function workHubSubmissionCanCorrect( + result: WorkHubSubmission, +): result is Extract { + return result.kind === 'submitted' && !result.steered; +} + export async function submitWorkHubSurfaceInput(input: { controller: WorkHubController; input: WorkHubSubmitInput; @@ -204,7 +210,11 @@ export function WorkHubSurface(props: { requestId: turn.requestId, text: turn.text, explicitTarget: target, - correction: { from }, + correction: { + from: from.target, + turnId: from.turnId, + ...(from.steered ? { steered: true } : {}), + }, })} onOpenSession={props.onOpenSession} /> @@ -224,7 +234,10 @@ function WorkHubTurnView(props: { copy: ReturnType; pending: boolean; onChoose(target: { sessionId: string }): void; - onCorrect(from: { sessionId: string }, target: { sessionId: string }): void; + onCorrect( + from: Extract, + target: { sessionId: string }, + ): void; onOpenSession(sessionId: string): void; }) { const { turn, copy } = props; @@ -287,13 +300,15 @@ function WorkHubTurnView(props: { : undefined} targetSessionId={submitted.target.sessionId} copy={copy} - correctionOptions={props.projection.sessions.filter( - (session) => - !session.archived && - session.target.sessionId !== submitted.target.sessionId, - )} + correctionOptions={workHubSubmissionCanCorrect(submitted) + ? props.projection.sessions.filter( + (session) => + !session.archived && + session.target.sessionId !== submitted.target.sessionId, + ) + : []} pending={props.pending} - onCorrect={(target) => props.onCorrect(submitted.target, target)} + onCorrect={(target) => props.onCorrect(submitted, target)} onOpenSession={props.onOpenSession} /> ) : null} @@ -335,25 +350,27 @@ function SubmittedWorkView(props: { ) : null} {session?.latestResult ?

{session.latestResult}

: null} -
- {copy.correctTarget} -
- {props.correctionOptions.map((option) => ( - - ))} -
-
+ {props.correctionOptions.length > 0 ? ( +
+ {copy.correctTarget} +
+ {props.correctionOptions.map((option) => ( + + ))} +
+
+ ) : null} ); } From 6304748bf1825f09ed91e40b30d956fea04f3ef9 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sun, 23 Aug 2026 15:32:51 +0800 Subject: [PATCH 4/6] fix: distinguish one-character Session names Generated-by: Codex --- .../main/__tests__/workhub-controller.test.ts | 26 ++++++++++++ .../src/renderer/workhub-route-policy.ts | 40 +++++++++++++++++-- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 9337743660..fc8122cb1e 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -244,6 +244,32 @@ test('a short Latin Session name does not match inside another word', async () = assert.deepEqual(submitted, ['parser-new']); }); +test('a one-character Latin discriminator prevents routing to a different Session name', async () => { + for (const { existingName, requestedName } of [ + { existingName: 'GPT-4', requestedName: 'GPT-3' }, + { existingName: 'Project A', requestedName: 'Project B' }, + ]) { + const submitted: string[] = []; + const sessions = port([ + session('existing', { sessionName: existingName }), + ]); + sessions.create = async ({ name }) => session('new', { sessionName: name }); + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: 'turn-new' }; + }; + + const result = await createWorkHubController({ sessions }).submit({ + requestId: `request-${requestedName}`, + text: `请处理 ${requestedName} 的问题`, + }); + + assert.equal(result.kind, 'submitted'); + assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); + assert.deepEqual(submitted, ['new']); + } +}); + test('submit asks the user when weak relevance matches more than one Session', async () => { const submitted: string[] = []; const sessions = port([ diff --git a/apps/desktop/src/renderer/workhub-route-policy.ts b/apps/desktop/src/renderer/workhub-route-policy.ts index e0f671644c..570031ba16 100644 --- a/apps/desktop/src/renderer/workhub-route-policy.ts +++ b/apps/desktop/src/renderer/workhub-route-policy.ts @@ -128,7 +128,12 @@ export function createWorkHubRoutePolicy(): WorkHubRoutePolicy { }; } - const related = rankRelatedSessions(text, sessions, originPromptBySessionId); + const relatedSessions = sessions.filter((session) => { + const qualifiedName = `${session.projectName}/${session.sessionName}`; + return !hasConflictingLatinIdentity(text, session.sessionName) && + !hasConflictingLatinIdentity(text, qualifiedName); + }); + const related = rankRelatedSessions(text, relatedSessions, originPromptBySessionId); if (looksLikeTargetUncertainty(text) && sessions.length > 0) { const relatedIds = new Set(related.map(({ session }) => session.target.sessionId)); const options = [ @@ -238,8 +243,8 @@ function exactIdentityMatchLength(input: string, identity: string): number { // punctuation is useful for Han names, but it must never make a short name // such as "AI" match inside an unrelated word such as "repair". if (/^[a-z0-9\s\p{P}\p{S}]+$/iu.test(identity)) { - const inputTokens = latinTokens(input); - const identityTokens = latinTokens(identity); + const inputTokens = exactLatinTokens(input); + const identityTokens = exactLatinTokens(identity); if ( identityTokens.length === 0 || identityTokens.length > inputTokens.length @@ -255,6 +260,31 @@ function exactIdentityMatchLength(input: string, identity: string): number { : 0; } +function hasConflictingLatinIdentity(input: string, identity: string): boolean { + if (!/^[a-z0-9\s\p{P}\p{S}]+$/iu.test(identity)) return false; + + const inputTokens = exactLatinTokens(input); + const identityTokens = exactLatinTokens(identity); + if ( + !identityTokens.some((token) => token.length === 1) || + identityTokens.length > inputTokens.length + ) return false; + + return inputTokens.some((_, start) => { + let hasConflict = false; + const sameIdentityExceptDiscriminator = identityTokens.every((token, offset) => { + const inputToken = inputTokens[start + offset]; + if (inputToken === token) return true; + if (token.length === 1 && inputToken?.length === 1) { + hasConflict = true; + return true; + } + return false; + }); + return sameIdentityExceptDiscriminator && hasConflict; + }); +} + function looksLikeRecentFocus(value: string): boolean { return /(?:它|这个(?:问题|工作|任务)?|这项(?:工作|任务)|刚才(?:那个|的)?|继续|接着|\bit\b|continue)/iu.test( value, @@ -383,6 +413,10 @@ function latinTokens(value: string): string[] { return value.toLocaleLowerCase().match(/[a-z0-9]{2,}/giu) ?? []; } +function exactLatinTokens(value: string): string[] { + return value.toLocaleLowerCase().match(/[a-z0-9]+/giu) ?? []; +} + function isLatinTerm(value: string): boolean { return /^[a-z0-9]+$/u.test(value); } From 5e8ac55ba70dd5767adc2e47440744c9397b33a1 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sun, 23 Aug 2026 17:55:41 +0800 Subject: [PATCH 5/6] fix(desktop): preserve granular queue operations after rebase --- .../src/main/runtime-host-session-execution-ipc-main.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index 2feb234fcc..93fa5c3816 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -73,7 +73,6 @@ type RuntimeHostSessionExecutionClient = Pick< | "queryTurnResume" | "readExecutionBoundary" | "regenerateTurn" - | "retractQueue" | "retractQueueEntry" | "promoteQueueEntry" | "reorderQueueEntries" @@ -484,13 +483,6 @@ export function registerRuntimeHostSessionExecutionIpc( }); }, ); - ipcMain.handle("sessions:retractQueue", async (_event, sessionId: string) => { - const result = await deps.client.retractQueue({ - sessionId, - retractId: newId(), - }); - return aggregateMessageContents(result.retracted.map((entry) => entry.content)); - }); ipcMain.handle( "sessions:stop", async (_event, sessionId: string, input: unknown) => { From bcc1fff80c61e83c0ed63cfac0de0b3c41b01d03 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Sun, 23 Aug 2026 19:01:16 +0800 Subject: [PATCH 6/6] chore(cli): add ASF headers to session status files --- .../src/__tests__/tui-session-status.test.ts | 19 +++++++++++++++++++ packages/cli/src/tui-session-status.ts | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/packages/cli/src/__tests__/tui-session-status.test.ts b/packages/cli/src/__tests__/tui-session-status.test.ts index f9e7089f74..1336d599e6 100644 --- a/packages/cli/src/__tests__/tui-session-status.test.ts +++ b/packages/cli/src/__tests__/tui-session-status.test.ts @@ -1,3 +1,22 @@ +/* + * 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 type { SessionSummary } from '@maka/core/session'; diff --git a/packages/cli/src/tui-session-status.ts b/packages/cli/src/tui-session-status.ts index 409d4eaa2b..40747880d9 100644 --- a/packages/cli/src/tui-session-status.ts +++ b/packages/cli/src/tui-session-status.ts @@ -1,3 +1,22 @@ +/* + * 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 type { SessionSummary } from '@maka/core/session'; import type { UiCatalog, UiLocale } from '@maka/core/ui-locale';