diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index db1b1e01e2..c1bf66eaa6 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -89,6 +89,7 @@ "src/renderer/locales/shell-copy.ts", "src/renderer/locales/shell-remaining-copy.ts", "src/renderer/locales/task-readiness-copy.ts", + "src/renderer/locales/workhub-copy.ts", "src/renderer/main.tsx", "src/renderer/mcp-brand-contrast.ts", "src/renderer/mcp-brand-marks.tsx", @@ -247,7 +248,6 @@ "src/renderer/workhub-coordination-host-scope.ts", "src/renderer/workhub-coordination-lifecycle.ts", "src/renderer/workhub-coordination-port.ts", - "src/renderer/workhub-route-policy.ts", "src/renderer/workhub-send-lease.ts", "src/renderer/workhub-session-port.ts", "src/renderer/workhub-surface.tsx", @@ -1791,6 +1791,15 @@ "actionFactories": [], "dependencyPaths": {} }, + "src/renderer/locales/workhub-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, "src/renderer/mcp-brand-contrast.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -4565,7 +4574,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "./workhub-route-policy.js": 1 + "./features/workhub/index.js": 1 } }, "src/renderer/workhub-coordination-host-scope.ts": { @@ -4603,17 +4612,6 @@ "@maka/core/session": 1 } }, - "src/renderer/workhub-route-policy.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./application/contracts/workhub-request-intent.js": 1 - } - }, "src/renderer/workhub-send-lease.ts": { "bridgePaths": {}, "environmentCapabilities": { @@ -4656,6 +4654,8 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "./features/workhub/index.js": 1, + "./locales/workhub-copy.js": 1, "./workhub-coordination-port.js": 1, "./workhub-send-lease.js": 1, "@astryxdesign/core": 1, diff --git a/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts b/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts new file mode 100644 index 0000000000..e8cedaa108 --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts @@ -0,0 +1,133 @@ +/* + * 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 { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { WorkHubSessionSummary } from "../../renderer/workhub-controller.js"; +import { + deriveWorkHubAnchors, + matchesWorkHubFilter, + MAX_WORKHUB_ANCHORS, + WorkHubNavigationRail, +} from "../../renderer/features/workhub/index.js"; +import { getWorkHubRailCopy } from "../../renderer/locales/workhub-copy.js"; + +function session( + sessionId: string, + state: WorkHubSessionSummary["state"], + updatedAt: number, + archived = false, +): WorkHubSessionSummary { + return { + target: { sessionId }, + projectName: "Maka", + sessionName: sessionId, + archived, + state, + updatedAt, + }; +} + +const sessions = [ + session("recent", "active", 100), + session("focus", "running", 10), + session("delegated", "waiting_for_user", 20), + session("blocked", "blocked", 90), + session("stopped", "aborted", 80), + session("archived", "active", 110, true), +]; + +test("anchors prioritize focus and delegation before recent Session facts", () => { + const before = structuredClone(sessions); + const anchors = deriveWorkHubAnchors({ + sessions, + focusSessionId: "focus", + delegatedSessionIds: ["delegated", "focus", "missing"], + filter: "all", + }); + assert.deepEqual(sessions, before); + assert.deepEqual(anchors.map((value) => value.target.sessionId), + ["focus", "delegated", "archived", "recent", "blocked", "stopped"]); + assert.deepEqual(deriveWorkHubAnchors({ sessions, delegatedSessionIds: ["delegated"], filter: "all" })[0], sessions[2]); +}); + +test("filters are derived from Session state and archive facts only", () => { + assert.deepEqual( + sessions + .filter((value) => matchesWorkHubFilter(value, "active")) + .map((value) => value.target.sessionId), + ["recent", "focus"], + ); + assert.deepEqual( + sessions + .filter((value) => matchesWorkHubFilter(value, "attention")) + .map((value) => value.target.sessionId), + ["delegated", "blocked"], + ); + assert.deepEqual( + sessions + .filter((value) => matchesWorkHubFilter(value, "stopped")) + .map((value) => value.target.sessionId), + ["stopped", "archived"], + ); +}); + +test("anchor projection is deduplicated and hard-bounded", () => { + const many = Array.from({ length: 20 }, (_, index) => + session(`session-${index}`, "active", index), + ); + const anchors = deriveWorkHubAnchors({ + sessions: [...many, many[0]!, many[4]!], + delegatedSessionIds: [...many, many[0]!].map((value) => value.target.sessionId), + filter: "all", + }); + assert.equal(anchors.length, MAX_WORKHUB_ANCHORS); + assert.equal( + new Set(anchors.map((value) => value.target.sessionId)).size, + anchors.length, + ); +}); + +test("rail copy distinguishes bounded anchors from all matching work", () => { + const many = Array.from({ length: 20 }, (_, index) => + session(`session-${index}`, "active", index), + ); + const markup = renderToStaticMarkup(createElement(WorkHubNavigationRail, { + locale: "en", + sessions: many, + delegatedSessionIds: [], + copy: getWorkHubRailCopy("en"), + onOpenSession: () => undefined, + })); + + assert.match(markup, /8\/20 anchors · 20 total/u); + assert.equal(markup.match(/]/gu)?.length, 8); +}); + + +test("focus display is derived from the selected Session ID, not delegation priority", () => { + const markup = renderToStaticMarkup(createElement(WorkHubNavigationRail, { + locale: "en", sessions, focusSessionId: "focus", delegatedSessionIds: ["delegated"], + copy: getWorkHubRailCopy("en"), onOpenSession: () => undefined, + })); + assert.equal(markup.match(/aria-current="page"/gu)?.length, 1); + assert.equal(markup.match(/Focused · Running/gu)?.length, 1); +}); diff --git a/apps/desktop/src/main/__tests__/workhub-controller-fixture.ts b/apps/desktop/src/main/__tests__/workhub-controller-fixture.ts new file mode 100644 index 0000000000..4f859c6817 --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-controller-fixture.ts @@ -0,0 +1,232 @@ +/* + * 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 { WorkHubCoordinationActInput } from '@maka/runtime-host/protocol'; +import { createWorkHubController as createGatedWorkHubController, type WorkHubSessionFacts, type WorkHubSessionPort, type WorkHubCoordinationTurn, type WorkHubSubmission } from '../../renderer/workhub-controller.js'; +import { createWorkHubR24RoutingStrategy, createWorkHubR3ARoutingStrategy, createWorkHubR3BRoutingStrategy, type WorkHubRoutingModelPort, type WorkHubRoutingStrategy } from '../../renderer/features/workhub/index.js'; + +export function session( + sessionId: string, + overrides: Partial = {}, +): WorkHubSessionFacts { + return { + target: { sessionId }, + projectName: 'maka', + sessionName: sessionId, + kind: 'ordinary', + archived: false, + state: 'active', + updatedAt: 1, + ...overrides, + }; +} + +export interface TestSessionPort extends WorkHubSessionPort { + create(input: { name: string }): Promise; + submit( + target: { sessionId: string }, + text: string, + turnId: string, + ): Promise<{ turnId: string; steered?: true }>; +} + +export function port(sessions: WorkHubSessionFacts[]): TestSessionPort { + let nextTurnId = 0; + return { + list: async () => sessions, + recentTurns: async () => [], + delegationFeedback: async (references) => + references.map(({ delegationId }) => ({ delegationId, state: 'accepted' })), + routingEvidence: async () => [], + create: async () => { + throw new Error('create is not used by this read test'); + }, + submit: async (_target, _text, turnId) => ({ + turnId: turnId || `reserved-turn-${++nextTurnId}`, + }), + subscribe: () => () => {}, + }; +} + +export function createWorkHubController({ + sessions, + routingStrategy, + transcript = [], + candidateSetId = `sha256:${"a".repeat(64)}`, + onAct, +}: { + sessions: TestSessionPort; + routingStrategy?: WorkHubRoutingStrategy; + transcript?: readonly WorkHubCoordinationTurn[]; + candidateSetId?: string; + onAct?: (input: WorkHubCoordinationActInput) => void; +}) { + let candidateByRef = new Map(); + return createGatedWorkHubController({ + sessions, + ...(routingStrategy ? { routingStrategy } : {}), + coordination: { + open: async (handler) => { handler(transcript); return { close: async () => undefined }; }, + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => { + const candidates = (await sessions.list()) + .filter((entry) => entry.kind === 'ordinary' && !entry.archived) + .map((entry) => ({ + candidateRef: `candidate-${entry.target.sessionId}`, + sessionId: entry.target.sessionId, + sessionName: entry.sessionName, + workspace: { + target: { kind: 'host_path' as const, path: `/workspace/${entry.target.sessionId}` }, + hostCwd: `/workspace/${entry.target.sessionId}`, + }, + state: entry.state, + updatedAt: entry.updatedAt, + })); + const byId = new Map( + (await sessions.list()).map((entry) => [entry.target.sessionId, entry]), + ); + candidateByRef = new Map(candidates.flatMap((candidate) => { + const entry = byId.get(candidate.sessionId); + return entry ? [[candidate.candidateRef, entry] as const] : []; + })); + return { + candidateSetId, + candidates, + }; + }, + act: async (input) => { + onAct?.(input); + if (input.proposal.disposition === 'answer_here') { + return { + disposition: 'answer_here', + coordinationTurnId: input.actionId, + }; + } + if (input.proposal.disposition === 'clarify') { + return { + disposition: 'clarify', + coordinationTurnId: input.actionId, + }; + } + if (input.proposal.disposition === 'create_new') { + const created = await sessions.create({ name: input.proposal.title }); + const admitted = await sessions.submit(created.target, input.userText, input.actionId); + return { + disposition: 'create_new', + targetSessionId: created.target.sessionId, + targetTurnId: admitted.turnId, + ...(admitted.steered ? { steered: true as const } : {}), + }; + } + if (input.proposal.disposition === 'replace') { + if (input.proposal.target.disposition === 'create_new') { + const created = await sessions.create({ name: input.proposal.target.title }); + const admitted = await sessions.submit(created.target, input.userText, input.actionId); + return { + disposition: 'replace', + replacementDisposition: 'create_new', + targetSessionId: created.target.sessionId, + targetTurnId: admitted.turnId, + ...(admitted.steered ? { steered: true as const } : {}), + }; + } + const replacementTarget = candidateByRef.get(input.proposal.target.candidateRef); + if (!replacementTarget) throw new Error('unknown test replacement candidate'); + const admitted = await sessions.submit( + replacementTarget.target, + input.userText, + input.actionId, + ); + return { + disposition: 'replace', + replacementDisposition: 'delegate_existing', + targetSessionId: replacementTarget.target.sessionId, + targetTurnId: admitted.turnId, + ...(admitted.steered ? { steered: true as const } : {}), + }; + } + if (input.proposal.disposition === 'stop_work') { + return { + disposition: 'stop_work', + outcome: 'cancelled_pending', + targetSessionId: input.proposal.expects.targetSessionId, + }; + } + if (input.proposal.disposition === 'resume_work') { + return { + disposition: 'resume_work', + outcome: 'resume_started', + targetSessionId: input.proposal.expects.targetSessionId, + targetTurnId: 'resumed-turn', + }; + } + const target = candidateByRef.get(input.proposal.candidateRef); + if (!target) throw new Error('unknown test candidate'); + const admitted = await sessions.submit(target.target, input.userText, input.actionId); + return { + disposition: 'delegate_existing', + targetSessionId: target.target.sessionId, + targetTurnId: admitted.turnId, + ...(admitted.steered ? { steered: true as const } : {}), + }; + }, + }, + }); +} + + +/** Repeatable comparison through the real controller; only Host execution is stubbed. */ +export async function runRoutingComparison(input: { + repetitions: number; + sessions: readonly WorkHubSessionFacts[]; + transcript: readonly WorkHubCoordinationTurn[]; + candidateSetId: string; + cases: readonly { caseId: string; text: string }[]; + model: WorkHubRoutingModelPort; +}) { + if (!Number.isSafeInteger(input.repetitions) || input.repetitions < 1) { + throw new Error('repetitions must be a positive integer'); + } + const observations: Array<{ repetition: number; caseId: string; result: WorkHubSubmission; proposals: WorkHubCoordinationActInput[] }> = []; + for (let repetition = 0; repetition < input.repetitions; repetition += 1) { + for (const routingStrategy of [createWorkHubR24RoutingStrategy(), createWorkHubR3ARoutingStrategy({ model: input.model }), createWorkHubR3BRoutingStrategy({ model: input.model })]) { + const facts = structuredClone([...input.sessions]); + const sessions = port(facts); + sessions.create = async ({ name }) => { + const created = session(`created-${facts.length}`, { sessionName: name }); + facts.push(created); + return created; + }; + const proposals: WorkHubCoordinationActInput[] = []; + const controller = createWorkHubController({ sessions, routingStrategy, + transcript: structuredClone([...input.transcript]), candidateSetId: input.candidateSetId, + onAct: (proposal) => proposals.push(proposal), + }); + const conversation = await controller.openConversation(() => {}, (error) => { throw error; }); + try { + for (const entry of input.cases) { + const start = proposals.length; + const result = await controller.submit({ requestId: `${repetition}:${routingStrategy.strategyId}:${entry.caseId}`, text: entry.text }); + observations.push({ repetition, caseId: entry.caseId, result, proposals: proposals.slice(start) }); + } + } finally { await conversation.close(); } + } + } + return observations; +} diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index 8c9823f6b8..b8f0fc0fb1 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -18,6 +18,7 @@ */ import assert from 'node:assert/strict'; +import { createWorkHubController, port, session } from './workhub-controller-fixture.js'; import { existsSync, readFileSync } from 'node:fs'; import test from 'node:test'; import type { WorkHubCoordinationActInput } from '@maka/runtime-host/protocol'; @@ -32,7 +33,14 @@ import { import { createWorkHubRoutePolicy, workHubNewSessionName, -} from '../../renderer/workhub-route-policy.js'; +} from '../../renderer/features/workhub/index.js'; +import { + createWorkHubR24RoutingStrategy, + createWorkHubR3BRoutingStrategy, + createWorkHubR3ARoutingStrategy, + WORKHUB_R3A_ROUTING_STRATEGY_ID, + type WorkHubRoutingStrategy, +} from '../../renderer/features/workhub/index.js'; const appShellUrl = [ new URL('../../renderer/app-shell.tsx', import.meta.url), @@ -60,161 +68,6 @@ test('binds the WorkHub controller to one Coordination identity rather than proj ); }); -function session( - sessionId: string, - overrides: Partial = {}, -): WorkHubSessionFacts { - return { - target: { sessionId }, - projectName: 'maka', - sessionName: sessionId, - kind: 'ordinary', - archived: false, - state: 'active', - updatedAt: 1, - ...overrides, - }; -} - -interface TestSessionPort extends WorkHubSessionPort { - create(input: { name: string }): Promise; - submit( - target: { sessionId: string }, - text: string, - turnId: string, - ): Promise<{ turnId: string; steered?: true }>; -} - -function port(sessions: WorkHubSessionFacts[]): TestSessionPort { - let nextTurnId = 0; - return { - list: async () => sessions, - recentTurns: async () => [], - delegationFeedback: async (references) => - references.map(({ delegationId }) => ({ delegationId, state: 'accepted' })), - routingEvidence: async () => [], - create: async () => { - throw new Error('create is not used by this read test'); - }, - submit: async (_target, _text, turnId) => ({ - turnId: turnId || `reserved-turn-${++nextTurnId}`, - }), - subscribe: () => () => {}, - }; -} - -function createWorkHubController({ sessions }: { sessions: TestSessionPort }) { - let candidateByRef = new Map(); - return createGatedWorkHubController({ - sessions, - coordination: { - open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => { - const candidates = (await sessions.list()) - .filter((entry) => entry.kind === 'ordinary' && !entry.archived) - .map((entry) => ({ - candidateRef: `candidate-${entry.target.sessionId}`, - sessionId: entry.target.sessionId, - sessionName: entry.sessionName, - workspace: { - target: { kind: 'host_path' as const, path: `/workspace/${entry.target.sessionId}` }, - hostCwd: `/workspace/${entry.target.sessionId}`, - }, - state: entry.state, - updatedAt: entry.updatedAt, - })); - const byId = new Map( - (await sessions.list()).map((entry) => [entry.target.sessionId, entry]), - ); - candidateByRef = new Map(candidates.flatMap((candidate) => { - const entry = byId.get(candidate.sessionId); - return entry ? [[candidate.candidateRef, entry] as const] : []; - })); - return { - candidateSetId: `sha256:${'a'.repeat(64)}`, - candidates, - }; - }, - act: async (input) => { - if (input.proposal.disposition === 'answer_here') { - return { - disposition: 'answer_here', - coordinationTurnId: input.actionId, - }; - } - if (input.proposal.disposition === 'clarify') { - return { - disposition: 'clarify', - coordinationTurnId: input.actionId, - }; - } - if (input.proposal.disposition === 'create_new') { - const created = await sessions.create({ name: input.proposal.title }); - const admitted = await sessions.submit(created.target, input.userText, input.actionId); - return { - disposition: 'create_new', - targetSessionId: created.target.sessionId, - targetTurnId: admitted.turnId, - ...(admitted.steered ? { steered: true as const } : {}), - }; - } - if (input.proposal.disposition === 'replace') { - if (input.proposal.target.disposition === 'create_new') { - const created = await sessions.create({ name: input.proposal.target.title }); - const admitted = await sessions.submit(created.target, input.userText, input.actionId); - return { - disposition: 'replace', - replacementDisposition: 'create_new', - targetSessionId: created.target.sessionId, - targetTurnId: admitted.turnId, - ...(admitted.steered ? { steered: true as const } : {}), - }; - } - const replacementTarget = candidateByRef.get(input.proposal.target.candidateRef); - if (!replacementTarget) throw new Error('unknown test replacement candidate'); - const admitted = await sessions.submit( - replacementTarget.target, - input.userText, - input.actionId, - ); - return { - disposition: 'replace', - replacementDisposition: 'delegate_existing', - targetSessionId: replacementTarget.target.sessionId, - targetTurnId: admitted.turnId, - ...(admitted.steered ? { steered: true as const } : {}), - }; - } - if (input.proposal.disposition === 'stop_work') { - return { - disposition: 'stop_work', - outcome: 'cancelled_pending', - targetSessionId: input.proposal.expects.targetSessionId, - }; - } - if (input.proposal.disposition === 'resume_work') { - return { - disposition: 'resume_work', - outcome: 'resume_started', - targetSessionId: input.proposal.expects.targetSessionId, - targetTurnId: 'resumed-turn', - }; - } - const target = candidateByRef.get(input.proposal.candidateRef); - if (!target) throw new Error('unknown test candidate'); - const admitted = await sessions.submit(target.target, input.userText, input.actionId); - return { - disposition: 'delegate_existing', - targetSessionId: target.target.sessionId, - targetTurnId: admitted.turnId, - ...(admitted.steered ? { steered: true as const } : {}), - }; - }, - }, - }); -} - function coordinationAssignmentTurn(): WorkHubCoordinationTurn { return { messageId: 'assignment-1', @@ -249,6 +102,7 @@ test('conversation acknowledges a durable assignment before projecting target ex references.map(({ delegationId }) => ({ delegationId, state: feedbackState })); const assignment = coordinationAssignmentTurn(); const snapshots: string[] = []; + const activeSnapshots: string[][] = []; const controller = createGatedWorkHubController({ sessions, coordination: { @@ -264,10 +118,12 @@ test('conversation acknowledges a durable assignment before projecting target ex const handle = await controller.openConversation((turns) => { snapshots.push(turns[0]?.assignment?.feedbackState ?? 'missing'); + activeSnapshots.push(turns.flatMap((turn) => turn.assignment?.linkState === 'active' ? [turn.assignment.targetSessionId] : [])); }, () => undefined); await Promise.resolve(); assert.deepEqual(snapshots.slice(0, 2), ['accepted', 'completed']); + assert.deepEqual(activeSnapshots, [['payment'], ['payment']]); feedbackState = 'waiting_for_user'; onSessionChanged?.(); @@ -891,6 +747,42 @@ test('submit routes a unique complete Session name without asking', async () => evidence: 'exact_session_name', }); assert.deepEqual(submitted, ['payment']); + assert.equal((await controller.read()).focusSessionId, 'payment'); +}); + +test('an injected R3 strategy still delegates through the shared controller and coordination.act port', async () => { + const submitted: string[] = []; + const sessions = port([ + session('login', { sessionName: '登录刷新令牌' }), + session('payment', { sessionName: '支付回调幂等性' }), + ]); + sessions.submit = async (target) => { + submitted.push(target.sessionId); + return { turnId: 'turn-model-payment' }; + }; + const routingStrategy = createWorkHubR3ARoutingStrategy({ + model: { + decide: async (input) => input.stage === 'intent' + ? { intent: 'work' } + : { kind: 'ranked', candidateRefs: ['candidate-payment'] }, + }, + }); + const controller = createWorkHubController({ sessions, routingStrategy }); + + const result = await controller.submit({ + requestId: 'request-r3-a', + text: '请实现账本边界检查器', + }); + + assert.deepEqual(result, { + kind: 'submitted', + strategyId: WORKHUB_R3A_ROUTING_STRATEGY_ID, + requestId: 'request-r3-a', + target: { sessionId: 'payment' }, + turnId: 'turn-model-payment', + evidence: 'model_candidate', + }); + assert.deepEqual(submitted, ['payment']); }); test('a unique longer Session name outranks a generic contained Session name', async () => { @@ -3311,3 +3203,73 @@ test('subscribe exposes Session invalidations without inventing WorkHub state', assert.equal(invalidations, 1); assert.equal(unsubscribed, true); }); + +for (const createStrategy of [createWorkHubR24RoutingStrategy, () => createWorkHubR3ARoutingStrategy({ model: { decide: async () => assert.fail('named resume must not invoke a model') } }), () => createWorkHubR3BRoutingStrategy({ model: { decide: async () => assert.fail('named resume must not invoke a model') } })]) { + const routingStrategy = createStrategy(); + test(`named resume retains ${routingStrategy.strategyId} through the shared coordination.act port`, async () => { + const controller = createGatedWorkHubController({ + sessions: port([session('payments', { sessionName: 'Payments' })]), + routingStrategy, + coordination: { + open: async () => ({ close: async () => undefined }), + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => ({ candidateSetId: `sha256:${'e'.repeat(64)}`, candidates: [{ candidateRef: 'payments-ref', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }] }), + act: async (input) => { + assert.equal(input.proposal.disposition, 'resume_work'); + assert.equal(input.proposal.resumesActionId, 'source-action'); + return { disposition: 'resume_work', outcome: 'resume_started', targetSessionId: 'payments', targetTurnId: 'resumed-turn' }; + }, + }, + }); + const result = await controller.submit({ requestId: 'resume-strategy', text: 'Resume Payments' }); + assert.equal(result.kind, 'resume'); + assert.equal(result.strategyId, routingStrategy.strategyId); + if (result.kind === 'resume') assert.equal(result.outcome, 'resume_started'); + }); +} + +for (const makeStrategy of [createWorkHubR24RoutingStrategy, () => createWorkHubR3ARoutingStrategy({ model: { decide: async (input) => input.stage === 'intent' ? { intent: 'work' } : { kind: 'none' } } }), () => createWorkHubR3BRoutingStrategy({ model: { decide: async () => ({ intent: 'work' }) } })]) { + test(`all combinations preserve Policy exact naming outside model recall budget: ${makeStrategy().strategyId}`, async () => { + const entries = Array.from({ length: 14 }, (_, i) => session(`work-${i}`, { sessionName: `任务编号${i}边界`, updatedAt: 14 - i })); + const controller = createWorkHubController({ sessions: port(entries), routingStrategy: makeStrategy() }); + const result = await controller.submit({ requestId: 'outside-recall-budget', text: '任务编号13边界:补充测试' }); + assert.equal(result.kind, 'submitted'); + if (result.kind === 'submitted') assert.equal(result.target.sessionId, 'work-13'); + }); +} + +test('Policy freezes visit focus before awaiting replaceable Intent', async () => { + let release!: () => void; + let started!: () => void; + const entered = new Promise((resolve) => { started = resolve; }); + const pending = new Promise((resolve) => { release = resolve; }); + const strategy = createWorkHubR24RoutingStrategy(); + const controller = createWorkHubController({ + sessions: port([session('login'), session('payment')]), + routingStrategy: { ...strategy, intent: { async classify(input) { + started(); + await pending; + return strategy.intent.classify(input); + } } }, + }); + await controller.read({ focus: { sessionId: 'login' } }); + const result = controller.submit({ requestId: 'frozen-focus', text: '继续它' }); + await entered; + await controller.read({ focus: { sessionId: 'payment' } }); + release(); + const submitted = await result; + assert.equal(submitted.kind, 'submitted'); + if (submitted.kind === 'submitted') assert.equal(submitted.target.sessionId, 'login'); +}); + +test('deterministic routing preserves executable instructions after the model text cutoff', async () => { + const sessions = port([]); + sessions.create = async () => session('ledger'); + const controller = createWorkHubController({ sessions }); + const result = await controller.submit({ + requestId: 'long-executable-input', + text: '背景资料:' + '日志内容。'.repeat(450) + '\n请实现账本边界检查器', + }); + assert.equal(result.kind, 'submitted'); + if (result.kind === 'submitted') assert.equal(result.target.sessionId, 'ledger'); +}); diff --git a/apps/desktop/src/main/__tests__/workhub-routing-experiment.test.ts b/apps/desktop/src/main/__tests__/workhub-routing-experiment.test.ts new file mode 100644 index 0000000000..03c1d45d56 --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-routing-experiment.test.ts @@ -0,0 +1,51 @@ +/* + * 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 { runRoutingComparison, session } from './workhub-controller-fixture.js'; + +test('comparison hydrates the same transcript and candidate set into every fresh controller', async () => { + const sessions = [session('payment', { sessionName: '支付回调幂等性' })]; + const transcript = [{ messageId: 'history', turnId: 'history', text: '此前讨论了支付重试', result: '保留幂等性约束', state: 'completed' as const, updatedAt: 1 }]; + const before = structuredClone({ sessions, transcript }); + let intentCalls = 0; + const candidateSetId = `sha256:${'c'.repeat(64)}`; + const observations = await runRoutingComparison({ repetitions: 2, sessions, transcript, candidateSetId, + cases: [{ caseId: 'payment', text: '支付回调幂等性:补充重复投递测试' }], + model: { async decide(input) { + if (input.stage === 'resolver') return { kind: 'ranked', candidateRefs: ['candidate-payment'] }; + intentCalls += 1; + assert.deepEqual(input.coordinationTranscript, [{ userText: transcript[0]!.text, assistantText: transcript[0]!.result }]); + return { intent: 'work' }; + } }, + }); + assert.equal(intentCalls, 4); + assert.equal(observations.length, 6); + assert.equal(new Set(observations.map(({ result }) => result.strategyId)).size, 3); + for (const { result, proposals } of observations) { + assert.equal(result.kind, 'submitted'); + if (result.kind === 'submitted') assert.equal(result.target.sessionId, 'payment'); + assert.equal(proposals.length, 1); + const proposal = proposals[0]!.proposal; + assert.equal(proposal.disposition, "delegate_existing"); + if (proposal.disposition === "delegate_existing") assert.equal(proposals[0]!.candidateSetId, candidateSetId); + } + assert.deepEqual({ sessions, transcript }, before); +}); diff --git a/apps/desktop/src/main/__tests__/workhub-routing-strategy.test.ts b/apps/desktop/src/main/__tests__/workhub-routing-strategy.test.ts new file mode 100644 index 0000000000..fd34d9fd0d --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-routing-strategy.test.ts @@ -0,0 +1,264 @@ +/* + * 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 { createWorkHubController, port, session } from './workhub-controller-fixture.js'; +import { + boundedRoutingInput, + createWorkHubR24RoutingStrategy, + createWorkHubR3ARoutingStrategy, + createWorkHubR3BRoutingStrategy, + readWorkHubRoutingEvidence, + type WorkHubRoutingInput, + type WorkHubRoutingStrategy, + type WorkHubModelRoutingRequest, +} from '../../renderer/features/workhub/index.js'; + +const sessions = [ + { + target: { sessionId: 'login' }, + projectName: 'maka', + sessionName: '登录刷新令牌', + state: 'active' as const, + updatedAt: 2, + }, + { + target: { sessionId: 'payment' }, + projectName: 'maka', + sessionName: '支付回调幂等性', + state: 'active' as const, + updatedAt: 1, + }, +]; +function fixture(text = '请实现账本边界检查器'): WorkHubRoutingInput { + return { + text, + sessions, + originPromptBySessionId: new Map(), + candidateRefBySessionId: new Map([ + ['login', 'candidate-login'], + ['payment', 'candidate-payment'], + ]), + coordinationTranscript: [], + }; +} +async function run(strategy: WorkHubRoutingStrategy, raw = fixture()) { + const sessions = port(raw.sessions.map((value) => session(value.target.sessionId, value))); + sessions.routingEvidence = async () => [...raw.originPromptBySessionId].map(([sessionId, originPrompt]) => ({ target: { sessionId }, originPrompt })); + sessions.create = async ({ name }) => session('created', { sessionName: name }); + const controller = createWorkHubController({ sessions, routingStrategy: strategy }); + return controller.submit({ requestId: 'combination', text: raw.text }); +} +const model = { + async decide(input: WorkHubModelRoutingRequest) { + return input.stage === 'intent' + ? { intent: 'work' } + : { kind: 'ranked', candidateRefs: ['candidate-payment'] }; + }, +}; + +test('a strategy combines two independent ports and has no decision or focus owner', async () => { + const baseline = createWorkHubR24RoutingStrategy(); + const r3 = createWorkHubR3ARoutingStrategy({ model }); + assert.deepEqual(Object.keys(r3).sort(), ['intent', 'resolver', 'strategyId']); + const intentOnly = { ...baseline, intent: r3.intent }; + const resolverOnly = { ...baseline, resolver: r3.resolver }; + assert.equal((await run(intentOnly, fixture('支付回调幂等性:补充测试'))).kind, 'submitted'); + const result = await run(resolverOnly); + assert.equal(result.kind, 'submitted'); + if (result.kind === 'submitted') assert.equal(result.target.sessionId, 'payment'); +}); + +test('R3-A calls separate intent and recall components; neither sees Session IDs', async () => { + const requests: WorkHubModelRoutingRequest[] = []; + const strategy = createWorkHubR3ARoutingStrategy({ + model: { + async decide(input) { + requests.push(input); + return model.decide(input); + }, + }, + }); + await run(strategy); + assert.deepEqual( + requests.map(({ stage }) => stage), + ['intent', 'resolver'], + ); + assert.equal('candidates' in requests[0]!, false); + assert.equal('disposition' in requests[1]!, false); + assert.equal(JSON.stringify(requests).includes('sessionId'), false); +}); + +test('R3-B replaces only Intent and reuses the deterministic Resolver', async () => { + const stages: string[] = []; + const strategy = createWorkHubR3BRoutingStrategy({ + model: { + async decide(input) { + stages.push(input.stage); + return { intent: 'work' }; + }, + }, + }); + assert.equal((await run(strategy, fixture('支付回调幂等性:补充测试'))).kind, 'submitted'); + assert.deepEqual(stages, ['intent']); +}); + +test('all combinations see the same bounded candidate snapshot, including deterministic recall', async () => { + const raw = fixture(); + const large = Array.from({ length: 14 }, (_, i) => ({ + ...sessions[0]!, + target: { sessionId: `session-${i}` }, + sessionName: `工作 ${i}`, + updatedAt: 14 - i, + })); + const input = boundedRoutingInput({ + ...raw, + text: '工作 13', + sessions: large, + candidateRefBySessionId: new Map( + large.map((session, i) => [session.target.sessionId, `ref-${i}`]), + ), + }); + assert.equal(input.sessions.length, 12); + for (const strategy of [ + createWorkHubR24RoutingStrategy(), + createWorkHubR3ARoutingStrategy({ model }), + createWorkHubR3BRoutingStrategy({ model }), + ]) { + const seen: string[][] = []; + const wrapped = { + ...strategy, + resolver: { + async resolve(value: Parameters[0]) { + seen.push(value.candidates.map(({ candidateRef }) => candidateRef)); + return strategy.resolver.resolve(value); + }, + }, + }; + await readWorkHubRoutingEvidence(wrapped, input); + assert.deepEqual(seen, [Array.from({ length: 12 }, (_, i) => `ref-${i}`)]); + } +}); + +test('model text is bounded at the adapter while deterministic components keep full text', async () => { + const input = boundedRoutingInput({ + ...fixture('😀'.repeat(3000)), + sessions: sessions.map((session) => ({ + ...session, + sessionName: '名'.repeat(1000), + latestResult: '结'.repeat(1000), + })), + originPromptBySessionId: new Map([['login', '源'.repeat(1000)]]), + coordinationTranscript: Array.from({ length: 20 }, () => ({ userText: '文'.repeat(1000) })), + }); + assert.equal(Array.from(input.text).length, 3000); + const requests: WorkHubModelRoutingRequest[] = []; + await readWorkHubRoutingEvidence(createWorkHubR3ARoutingStrategy({ model: { async decide(value) { requests.push(value); return value.stage === "intent" ? { intent: "work" } : { kind: "none" }; } } }), input); + assert.equal(requests.length, 2); + assert.ok(requests.every((value) => Array.from(value.text).length === 2000)); + assert.equal(input.coordinationTranscript.length, 12); + assert.ok(input.sessions.every((session) => session.sessionName.length <= 600)); + assert.equal(input.originPromptBySessionId.get('login')?.length, 600); +}); + +for (const response of [ + null, + [], + { disposition: 'create_new' }, + { intent: 'work', target: 'payment' }, +]) { + test(`malformed intent cannot issue a proposal: ${JSON.stringify(response)}`, async () => { + const strategy = createWorkHubR3ARoutingStrategy({ model: { decide: async () => response } }); + const evidence = await readWorkHubRoutingEvidence(strategy, boundedRoutingInput(fixture())); + assert.equal(evidence.classification, 'uncertain'); + assert.equal(evidence.resolution.kind, 'ambiguous'); + assert.equal((await run(strategy)).kind, 'clarification'); + }); +} +for (const response of [ + null, + { kind: 'ranked', candidateRefs: ['invented'] }, + { kind: 'ranked', candidateRefs: ['candidate-payment', 'candidate-payment'] }, + { kind: 'ranked', candidateRefs: [] }, + { kind: 'none', disposition: 'create_new' }, + { kind: 'ranked', candidateRefs: ['candidate-payment'], target: 'payment' }, +]) { + test(`malformed recall fails closed: ${JSON.stringify(response)}`, async () => { + const strategy = createWorkHubR3ARoutingStrategy({ + model: { + decide: async (input) => (input.stage === 'intent' ? { intent: 'work' } : response), + }, + }); + const evidence = await readWorkHubRoutingEvidence(strategy, boundedRoutingInput(fixture())); + assert.equal(evidence.classification, 'uncertain'); + assert.equal(evidence.resolution.kind, 'ambiguous'); + assert.equal((await run(strategy)).kind, 'clarification'); + }); +} + +test('Policy retains ambiguity, exact naming and focus with model recall in the real controller', async () => { + const strategy = createWorkHubR3ARoutingStrategy({ model }); + assert.equal((await run(strategy, fixture('创建一个新任务,不过我还不确定是否要做'))).kind, 'clarification'); + const controller = createWorkHubController({ sessions: port(sessions.map((value) => session(value.target.sessionId, value))), routingStrategy: strategy }); + const exact = await controller.submit({ requestId: 'exact', text: '登录刷新令牌:补充测试' }); + assert.equal(exact.kind, 'submitted'); + if (exact.kind === 'submitted') assert.equal(exact.target.sessionId, 'login'); + const focused = await controller.submit({ requestId: 'focused', text: '继续它' }); + assert.equal(focused.kind, 'submitted'); + if (focused.kind === 'submitted') assert.equal(focused.target.sessionId, 'login'); +}); + +test('a ranked list is not a selected target: Policy clarifies multiple recalled candidates', async () => { + const strategy = createWorkHubR3ARoutingStrategy({ + model: { + decide: async (input) => + input.stage === 'intent' + ? { intent: 'work' } + : { kind: 'ranked', candidateRefs: ['candidate-payment', 'candidate-login'] }, + }, + }); + assert.equal((await run(strategy)).kind, 'clarification'); +}); + +test('model work intent cannot turn trusted discussion into creation or delegation', async () => { + const strategy = createWorkHubR3ARoutingStrategy({ model }); + const result = await run(strategy, fixture('讨论一下量子纠缠的概念')); + assert.notEqual(result.kind, 'submitted'); +}); + +test('trusted explicit creation is decided by Policy, never returned by a model', async () => { + const result = await run( + createWorkHubR3ARoutingStrategy({ model }), + fixture('创建一个新工作,检查账本边界'), + ); + assert.equal(result.kind, 'submitted'); + if (result.kind === 'submitted') assert.equal(result.target.sessionId, 'created'); +}); + +test('model exceptions become uncertain evidence rather than creating work', async () => { + const strategy = createWorkHubR3ARoutingStrategy({ + model: { + decide: async () => { + throw new Error('offline'); + }, + }, + }); + assert.equal((await run(strategy)).kind, 'clarification'); +}); diff --git a/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts index 6e9b2305df..893ba8db9b 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts @@ -23,7 +23,7 @@ import type { WorkHubSessionResolution, WorkHubSessionResolver, } from '../../renderer/application/contracts/workhub-request-intent.js'; -import { createWorkHubRoutePolicy } from '../../renderer/workhub-route-policy.js'; +import { createWorkHubRoutePolicy } from '../../renderer/features/workhub/index.js'; const routable = (sessionId: string, sessionName: string) => ({ target: { sessionId }, diff --git a/apps/desktop/src/renderer/features/workhub/index.ts b/apps/desktop/src/renderer/features/workhub/index.ts new file mode 100644 index 0000000000..10f1cf43e5 --- /dev/null +++ b/apps/desktop/src/renderer/features/workhub/index.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ + +export * from './model/anchor-rail.js'; +export * from './model/route-policy.js'; +export * from './model/routing-strategy.js'; +export { WorkHubNavigationRail } from './ui/workhub-navigation-rail.js'; + +export { WorkHubPromptRail } from './ui/workhub-prompt-rail.js'; diff --git a/apps/desktop/src/renderer/features/workhub/model/anchor-rail.ts b/apps/desktop/src/renderer/features/workhub/model/anchor-rail.ts new file mode 100644 index 0000000000..20430f1ea0 --- /dev/null +++ b/apps/desktop/src/renderer/features/workhub/model/anchor-rail.ts @@ -0,0 +1,85 @@ +/* + * 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. + */ + +export interface WorkHubAnchorSession { + readonly target: { readonly sessionId: string }; + readonly projectName: string; + readonly sessionName: string; + readonly archived: boolean; + readonly state: "active" | "running" | "waiting_for_user" | "blocked" | "aborted"; + readonly latestResult?: string; + readonly updatedAt: number; +} + +export type WorkHubWorkFilter = "all" | "active" | "attention" | "stopped"; +export const MAX_WORKHUB_ANCHORS = 8; + +/** + * Bounded, rebuildable navigation projection. It never changes the routing + * candidate set and owns no Session or delegation state. + */ +export function deriveWorkHubAnchors(input: { + readonly sessions: readonly WorkHubAnchorSession[]; + readonly focusSessionId?: string; + readonly delegatedSessionIds: readonly string[]; + readonly filter: WorkHubWorkFilter; +}): WorkHubAnchorSession[] { + const sessionById = new Map( + input.sessions.map((session) => [session.target.sessionId, session]), + ); + const ordered: WorkHubAnchorSession[] = []; + const seen = new Set(); + const append = ( + sessionId: string | undefined, + ) => { + if (!sessionId || seen.has(sessionId)) return; + const session = sessionById.get(sessionId); + if (!session || !matchesWorkHubFilter(session, input.filter)) return; + seen.add(sessionId); + ordered.push(session); + }; + + append(input.focusSessionId); + for (const sessionId of input.delegatedSessionIds) + append(sessionId); + for (const session of [...input.sessions].sort( + (left, right) => right.updatedAt - left.updatedAt, + )) { + append(session.target.sessionId); + } + return ordered.slice(0, MAX_WORKHUB_ANCHORS); +} + +export function matchesWorkHubFilter( + session: WorkHubAnchorSession, + filter: WorkHubWorkFilter, +): boolean { + if (filter === "all") return true; + if (filter === "active") + return ( + !session.archived && + (session.state === "active" || session.state === "running") + ); + if (filter === "attention") + return ( + !session.archived && + (session.state === "waiting_for_user" || session.state === "blocked") + ); + return session.archived || session.state === "aborted"; +} diff --git a/apps/desktop/src/renderer/workhub-route-policy.ts b/apps/desktop/src/renderer/features/workhub/model/route-policy.ts similarity index 90% rename from apps/desktop/src/renderer/workhub-route-policy.ts rename to apps/desktop/src/renderer/features/workhub/model/route-policy.ts index 90c855b7ef..16e0a5fa96 100644 --- a/apps/desktop/src/renderer/workhub-route-policy.ts +++ b/apps/desktop/src/renderer/features/workhub/model/route-policy.ts @@ -24,7 +24,7 @@ import { type WorkHubRequestIntent, type WorkHubResolverSession, type WorkHubSessionResolver, -} from './application/contracts/workhub-request-intent.js'; +} from '../../../application/contracts/workhub-request-intent.js'; interface WorkHubRouteTarget { sessionId: string; @@ -43,7 +43,8 @@ export type WorkHubRouteEvidence = | 'exact_session_name' | 'route_correction' | 'core_entity' - | 'recent_focus'; + | 'recent_focus' + | 'model_candidate'; export type WorkHubRouteDecision = | { @@ -115,8 +116,18 @@ export interface WorkHubRoutePolicy { sessions: WorkHubRoutableSession[]; originPromptBySessionId: ReadonlyMap; explicitTarget?: WorkHubRouteTarget; + interpretation?: { + readonly classification: 'work' | 'discussion' | 'uncertain'; + readonly resolution: 'none' | 'ranked' | 'ambiguous'; + readonly recalledSessionIds: readonly string[]; + }; }): WorkHubRouteDecision; initializeFocus(targets: readonly WorkHubRouteTarget[]): void; + focusSnapshot(): { + readonly current?: WorkHubRouteTarget; + readonly previous?: WorkHubRouteTarget; + }; + snapshot(): WorkHubRoutePolicy; newVisit(): WorkHubRoutePolicy; rememberTarget(target: WorkHubRouteTarget): void; } @@ -137,6 +148,14 @@ export function workHubNewSessionName( return firstClause?.slice(0, 48) || '新工作'; } +export function boundedWorkHubText(value: string, maxChars: number): string { + const text = value.trim(); + const chars = Array.from(text); + return chars.length <= maxChars + ? text + : `${chars.slice(0, maxChars - 1).join('')}…`; +} + const MIN_EXACT_SESSION_NAME_LENGTH = 2; // One four-character Han phrase is usually a meaningful entity rather than // grammar; Latin needs either two whole-word matches or one distinctive word. @@ -192,9 +211,13 @@ export function createWorkHubRoutePolicy( function createWorkHubRoutePolicyVisit( sessionResolver: WorkHubSessionResolver, + initial?: { + readonly current?: WorkHubRouteTarget; + readonly previous?: WorkHubRouteTarget; + }, ): WorkHubRoutePolicy { - let currentFocus: WorkHubRouteTarget | undefined; - let previousFocus: WorkHubRouteTarget | undefined; + let currentFocus = initial?.current; + let previousFocus = initial?.previous; return { // The stop Action Policy. Action Intent says only that the user issued a @@ -234,7 +257,7 @@ function createWorkHubRoutePolicyVisit( 'resume_target_ambiguous', ); }, - resolve({ text, sessions, originPromptBySessionId, explicitTarget }) { + resolve({ text, sessions, originPromptBySessionId, explicitTarget, interpretation }) { const intent = readWorkHubRequestIntent(text); if (intent.execution === 'ambiguous') { return { kind: 'clarification', options: [], reason: 'ambiguous_command' }; @@ -302,6 +325,11 @@ function createWorkHubRoutePolicyVisit( return { kind: 'new_session', title: workHubNewSessionName(text, intent) }; } + // A failed or uncertain interpretation never authorizes a guessed target. + if (interpretation?.classification === 'uncertain') { + return { kind: 'clarification', options: sessions.slice(0, MAX_UNCERTAINTY_OPTIONS) }; + } + const exact = rankExactSessions(text, sessions); if (exact[0] && exact[0].matchLength > (exact[1]?.matchLength ?? 0)) { return { @@ -379,7 +407,22 @@ function createWorkHubRoutePolicyVisit( .map(({ session }) => session), }; } - return looksExecutable(intent) + // Resolver output is ranked recall, not a final target. Policy requires + // trusted imperative text, one candidate, and no unresolved baseline evidence. + if (interpretation && interpretation.resolution !== 'none') { + const recalled = interpretation.recalledSessionIds.flatMap((id) => { + const session = sessions.find((candidate) => candidate.target.sessionId === id); + return session ? [session] : []; + }); + if (interpretation.classification === 'work' && looksExecutable(intent) && + interpretation.resolution === 'ranked' && recalled.length === 1) { + return { kind: 'target', target: recalled[0]!.target, evidence: 'model_candidate' }; + } + if (recalled.length > 0 || interpretation.resolution === 'ambiguous') { + return { kind: 'clarification', options: recalled.slice(0, MAX_UNCERTAINTY_OPTIONS) }; + } + } + return looksExecutable(intent) && interpretation?.classification !== 'discussion' ? { kind: 'new_session', title: workHubNewSessionName(text, intent) } : { kind: 'discussion' }; }, @@ -403,6 +446,18 @@ function createWorkHubRoutePolicyVisit( previousFocus = ordered.find((target) => target.sessionId !== currentFocus?.sessionId); } }, + focusSnapshot() { + return { + ...(currentFocus ? { current: currentFocus } : {}), + ...(previousFocus ? { previous: previousFocus } : {}), + }; + }, + snapshot() { + return createWorkHubRoutePolicyVisit(sessionResolver, { + ...(currentFocus ? { current: currentFocus } : {}), + ...(previousFocus ? { previous: previousFocus } : {}), + }); + }, newVisit() { return createWorkHubRoutePolicyVisit(sessionResolver); }, diff --git a/apps/desktop/src/renderer/features/workhub/model/routing-strategy.ts b/apps/desktop/src/renderer/features/workhub/model/routing-strategy.ts new file mode 100644 index 0000000000..7badac9bfa --- /dev/null +++ b/apps/desktop/src/renderer/features/workhub/model/routing-strategy.ts @@ -0,0 +1,301 @@ +/* + * 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 { boundedWorkHubText } from './route-policy.js'; +import { + createExactNameSessionResolver, + readWorkHubRequestIntent, +} from '../../../application/contracts/workhub-request-intent.js'; + +export interface WorkHubRoutingTarget { + readonly sessionId: string; +} +export interface WorkHubRoutingSessionFacts { + readonly target: WorkHubRoutingTarget; + readonly projectName: string; + readonly sessionName: string; + readonly state: 'active' | 'running' | 'waiting_for_user' | 'blocked' | 'aborted'; + readonly latestResult?: string; + readonly updatedAt: number; +} +export const WORKHUB_R24_ROUTING_STRATEGY_ID = 'wh-r2.4-session-context-continuity' as const; +export const WORKHUB_R3A_ROUTING_STRATEGY_ID = 'wh-r3.a-model-intent-model-resolver' as const; +export const WORKHUB_R3B_ROUTING_STRATEGY_ID = + 'wh-r3.b-model-intent-deterministic-resolver' as const; +export type WorkHubRoutingStrategyId = + | typeof WORKHUB_R24_ROUTING_STRATEGY_ID + | typeof WORKHUB_R3A_ROUTING_STRATEGY_ID + | typeof WORKHUB_R3B_ROUTING_STRATEGY_ID; +export interface WorkHubRoutingTranscriptTurn { + readonly userText: string; + readonly assistantText?: string; +} +export interface WorkHubRoutingInput { + readonly text: string; + readonly sessions: readonly WorkHubRoutingSessionFacts[]; + readonly originPromptBySessionId: ReadonlyMap; + readonly candidateRefBySessionId: ReadonlyMap; + readonly coordinationTranscript: readonly WorkHubRoutingTranscriptTurn[]; + readonly explicitTarget?: WorkHubRoutingTarget; +} + +/** Interpretations carry no target and cannot grant execution authority. */ +export type WorkHubIntentClassification = 'work' | 'discussion' | 'uncertain'; +export interface WorkHubIntentInput { + readonly text: string; + readonly coordinationTranscript: readonly WorkHubRoutingTranscriptTurn[]; +} +export interface WorkHubIntentClassifier { + classify(input: WorkHubIntentInput): Promise; +} +export interface WorkHubRecallCandidate { + readonly candidateRef: string; + readonly projectName: string; + readonly sessionName: string; + readonly state: WorkHubRoutingSessionFacts['state']; + readonly updatedAt: number; + readonly latestResult?: string; + readonly originPrompt?: string; +} +export interface WorkHubResolverInput { + readonly text: string; + readonly candidates: readonly WorkHubRecallCandidate[]; +} +/** Retrieval only. Neither a final target nor creation is a resolver result. */ +export type WorkHubRoutingResolution = + | { readonly kind: 'none' } + | { readonly kind: 'ranked' | 'ambiguous'; readonly candidateRefs: readonly string[] }; +export interface WorkHubRoutingResolver { + resolve(input: WorkHubResolverInput): Promise; +} +/** A named component combination, not another proposal owner. */ +export interface WorkHubRoutingStrategy { + readonly strategyId: WorkHubRoutingStrategyId; + readonly intent: WorkHubIntentClassifier; + readonly resolver: WorkHubRoutingResolver; +} +export type WorkHubModelRoutingRequest = + | ({ readonly stage: 'intent' } & WorkHubIntentInput) + | ({ readonly stage: 'resolver' } & WorkHubResolverInput); +/** Adapter output is untrusted; each component validates its own closed schema. */ +export interface WorkHubRoutingModelPort { + decide(input: WorkHubModelRoutingRequest): Promise; +} + +export function createWorkHubDeterministicIntent(): WorkHubIntentClassifier { + return { + async classify({ text }) { + const execution = readWorkHubRequestIntent(text).execution; + return execution === 'imperative' + ? 'work' + : execution === 'ambiguous' + ? 'uncertain' + : 'discussion'; + }, + }; +} +export function createWorkHubDeterministicResolver(): WorkHubRoutingResolver { + const resolver = createExactNameSessionResolver(); + return { + async resolve({ text, candidates }) { + const resolution = resolver.resolve({ + reference: { text }, + sessions: candidates.map((candidate) => ({ ...candidate, ref: candidate.candidateRef })), + }); + return resolution.kind === 'none' + ? resolution + : { + kind: resolution.kind, + candidateRefs: resolution.candidates.map(({ ref }) => ref), + }; + }, + }; +} +export function createWorkHubModelIntent(model: WorkHubRoutingModelPort): WorkHubIntentClassifier { + return { + async classify(input) { + const value = await model.decide({ stage: 'intent', ...input, text: boundedWorkHubText(input.text, MAX_MODEL_INPUT_CHARS) }); + if ( + !isRecord(value) || + Object.keys(value).length !== 1 || + !['work', 'discussion', 'uncertain'].includes(String(value.intent)) + ) { + throw new Error('Invalid WorkHub intent classification'); + } + return value.intent as WorkHubIntentClassification; + }, + }; +} +export function createWorkHubModelResolver(model: WorkHubRoutingModelPort): WorkHubRoutingResolver { + return { + async resolve(input) { + const value = await model.decide({ stage: 'resolver', ...input, text: boundedWorkHubText(input.text, MAX_MODEL_INPUT_CHARS) }); + if (!validResolution(value, input.candidates)) throw new Error('Invalid WorkHub recall'); + return value; + }, + }; +} +export function createWorkHubR24RoutingStrategy(): WorkHubRoutingStrategy { + return { + strategyId: WORKHUB_R24_ROUTING_STRATEGY_ID, + intent: createWorkHubDeterministicIntent(), + resolver: createWorkHubDeterministicResolver(), + }; +} +export function createWorkHubR3ARoutingStrategy({ + model, +}: { + readonly model: WorkHubRoutingModelPort; +}): WorkHubRoutingStrategy { + return { + strategyId: WORKHUB_R3A_ROUTING_STRATEGY_ID, + intent: createWorkHubModelIntent(model), + resolver: createWorkHubModelResolver(model), + }; +} +export function createWorkHubR3BRoutingStrategy({ + model, +}: { + readonly model: WorkHubRoutingModelPort; +}): WorkHubRoutingStrategy { + return { + strategyId: WORKHUB_R3B_ROUTING_STRATEGY_ID, + intent: createWorkHubModelIntent(model), + resolver: createWorkHubDeterministicResolver(), + }; +} + +/** Bound once at the shared controller boundary, before either component runs. */ +export async function readWorkHubRoutingEvidence( + strategy: WorkHubRoutingStrategy, + input: WorkHubRoutingInput, +): Promise<{ + readonly classification: WorkHubIntentClassification; + readonly resolution: WorkHubRoutingResolution; +}> { + const candidates = input.sessions.flatMap((session) => { + const candidateRef = input.candidateRefBySessionId.get(session.target.sessionId); + return candidateRef + ? [ + { + candidateRef, + projectName: session.projectName, + sessionName: session.sessionName, + state: session.state, + updatedAt: session.updatedAt, + latestResult: session.latestResult, + originPrompt: input.originPromptBySessionId.get(session.target.sessionId), + }, + ] + : []; + }); + try { + const classification = await strategy.intent.classify({ + text: input.text, + coordinationTranscript: input.coordinationTranscript, + }); + if (!['work', 'discussion', 'uncertain'].includes(classification)) + throw new Error('Invalid WorkHub intent'); + const resolution = await strategy.resolver.resolve({ text: input.text, candidates }); + if (!validResolution(resolution, candidates)) throw new Error('Invalid WorkHub recall'); + return { classification, resolution }; + } catch { + return { classification: 'uncertain', resolution: { kind: 'ambiguous', candidateRefs: [] } }; + } +} +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} +function validResolution( + value: unknown, + candidates: readonly WorkHubRecallCandidate[], +): value is WorkHubRoutingResolution { + if (!isRecord(value)) return false; + if (value.kind === 'none') return Object.keys(value).length === 1; + if ( + (value.kind !== 'ranked' && value.kind !== 'ambiguous') || + Object.keys(value).some((key) => key !== 'kind' && key !== 'candidateRefs') || + !Array.isArray(value.candidateRefs) || + value.candidateRefs.length > candidates.length || + (value.kind === 'ranked' && value.candidateRefs.length === 0) + ) + return false; + const allowed = new Set(candidates.map(({ candidateRef }) => candidateRef)); + return ( + new Set(value.candidateRefs).size === value.candidateRefs.length && + value.candidateRefs.every((ref) => typeof ref === 'string' && allowed.has(ref)) + ); +} +const MAX_ROUTING_CANDIDATES = 12; +const MAX_MODEL_TRANSCRIPT_TURNS = 12; +const MAX_MODEL_INPUT_CHARS = 2_000; +const MAX_MODEL_SUMMARY_CHARS = 600; + +export function boundedRoutingInput(input: WorkHubRoutingInput): WorkHubRoutingInput { + const sessions = [...input.sessions] + .filter((session) => input.candidateRefBySessionId.has(session.target.sessionId)) + .sort( + (left, right) => + right.updatedAt - left.updatedAt || + left.target.sessionId.localeCompare(right.target.sessionId), + ) + .slice(0, MAX_ROUTING_CANDIDATES) + .map((session) => ({ + ...session, + projectName: boundedWorkHubText(session.projectName, MAX_MODEL_SUMMARY_CHARS), + sessionName: boundedWorkHubText(session.sessionName, MAX_MODEL_SUMMARY_CHARS), + ...(session.latestResult === undefined + ? {} + : { + latestResult: boundedWorkHubText(session.latestResult, MAX_MODEL_SUMMARY_CHARS), + }), + })); + return { + text: input.text, + sessions, + originPromptBySessionId: new Map( + sessions.map((session) => { + const originPrompt = input.originPromptBySessionId.get(session.target.sessionId); + return [ + session.target.sessionId, + originPrompt === undefined + ? undefined + : boundedWorkHubText(originPrompt, MAX_MODEL_SUMMARY_CHARS), + ] as const; + }), + ), + candidateRefBySessionId: new Map( + sessions.map((session) => [ + session.target.sessionId, + input.candidateRefBySessionId.get(session.target.sessionId)!, + ]), + ), + coordinationTranscript: input.coordinationTranscript + .slice(-MAX_MODEL_TRANSCRIPT_TURNS) + .map((turn) => ({ + userText: boundedWorkHubText(turn.userText, MAX_MODEL_SUMMARY_CHARS), + ...(turn.assistantText === undefined + ? {} + : { + assistantText: boundedWorkHubText(turn.assistantText, MAX_MODEL_SUMMARY_CHARS), + }), + })), + ...(input.explicitTarget ? { explicitTarget: input.explicitTarget } : {}), + }; +} diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-navigation-rail.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-navigation-rail.tsx new file mode 100644 index 0000000000..857bb5fe14 --- /dev/null +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-navigation-rail.tsx @@ -0,0 +1,98 @@ +/* + * 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 { useState } from 'react'; +import type { WorkHubRailCopy } from '../../../locales/workhub-copy.js'; +import type { UiLocale } from '@maka/core/ui-locale'; +import { Button, dotForStatus, presentSessionStatus } from '@maka/ui'; +import { List, ListItem, StatusDot } from '@astryxdesign/core'; +import { + deriveWorkHubAnchors, + matchesWorkHubFilter, + type WorkHubAnchorSession, + type WorkHubWorkFilter, +} from '../model/anchor-rail.js'; + +export function WorkHubNavigationRail(props: { + readonly locale: UiLocale; + readonly sessions: readonly WorkHubAnchorSession[]; + readonly focusSessionId?: string; + readonly delegatedSessionIds: readonly string[]; + readonly copy: WorkHubRailCopy; + readonly onOpenSession: (sessionId: string) => void; +}) { + const [filter, setFilter] = useState('all'); + const anchors = deriveWorkHubAnchors({ + sessions: props.sessions, + focusSessionId: props.focusSessionId, + delegatedSessionIds: props.delegatedSessionIds, + filter, + }); + const matchingWorkCount = props.sessions.filter((session) => + matchesWorkHubFilter(session, filter)).length; + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-prompt-rail.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-prompt-rail.tsx new file mode 100644 index 0000000000..fb466370da --- /dev/null +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-prompt-rail.tsx @@ -0,0 +1,28 @@ +/* + * 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 { useChatLayoutContext } from '@astryxdesign/core/Chat'; +import { PromptAnchorRail, type PromptAnchorRailTurn } from '@maka/ui'; + +/** Uses the enclosing chat layout's scroller, never the Session navigation. */ +export function WorkHubPromptRail({ turns }: { turns: readonly PromptAnchorRailTurn[] }) { + const layout = useChatLayoutContext(); + if (!layout) throw new Error('WorkHubPromptRail requires ChatSurfaceLayout'); + return ; +} diff --git a/apps/desktop/src/renderer/locales/workhub-copy.ts b/apps/desktop/src/renderer/locales/workhub-copy.ts new file mode 100644 index 0000000000..d3f4ffdff6 --- /dev/null +++ b/apps/desktop/src/renderer/locales/workhub-copy.ts @@ -0,0 +1,86 @@ +/* + * 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 { UiCatalog, UiLocale } from '@maka/core/ui-locale'; + +import type { WorkHubWorkFilter, WorkHubAnchorSession } from '../features/workhub/index.js'; + +export interface WorkHubRailCopy { + readonly work: string; + readonly workNavigation: string; + readonly filterWork: string; + readonly focused: string; + readonly archived: string; + readonly states: Readonly>; + readonly anchorCount: (shown: number, matching: number, total: number) => string; + readonly noFilteredWork: string; + readonly filters: ReadonlyArray<{ + readonly id: WorkHubWorkFilter; + readonly label: string; + }>; +} + +const COPY = { + 'zh-CN': { + work: '工作', workNavigation: '工作导航', filterWork: '筛选工作', focused: '当前', + archived: '已归档', + states: { active: '活跃', running: '进行中', waiting_for_user: '等待你', blocked: '受阻', aborted: '已中止' }, + anchorCount: (shown, matching, total) => `${shown}/${matching} 个锚点 · 共 ${total} 项`, + noFilteredWork: '此筛选下没有工作', + filters: [ + { id: 'all', label: '全部' }, + { id: 'active', label: '进行中' }, + { id: 'attention', label: '待处理' }, + { id: 'stopped', label: '已停止' }, + ], + }, + 'zh-TW': { + work: '工作', workNavigation: '工作導覽', filterWork: '篩選工作', focused: '目前', + archived: '已封存', + states: { active: '使用中', running: '進行中', waiting_for_user: '等待你', blocked: '受阻', aborted: '已中止' }, + anchorCount: (shown, matching, total) => `${shown}/${matching} 個錨點 · 共 ${total} 項`, + noFilteredWork: '此篩選下沒有工作', + filters: [ + { id: 'all', label: '全部' }, + { id: 'active', label: '進行中' }, + { id: 'attention', label: '待處理' }, + { id: 'stopped', label: '已停止' }, + ], + }, + en: { + work: 'Work', workNavigation: 'Work navigation', filterWork: 'Filter work', focused: 'Focused', + archived: 'Archived', + states: { active: 'Active', running: 'Running', waiting_for_user: 'Waiting for you', blocked: 'Blocked', aborted: 'Aborted' }, + anchorCount: (shown, matching, total) => `${shown}/${matching} anchors · ${total} total`, + noFilteredWork: 'No work matches this filter', + filters: [ + { id: 'all', label: 'All' }, + { id: 'active', label: 'Active' }, + { id: 'attention', label: 'Needs you' }, + { id: 'stopped', label: 'Stopped' }, + ], + }, +} satisfies UiCatalog; + +export function getWorkHubRailCopy(locale: UiLocale): WorkHubRailCopy { + return COPY[locale]; +} diff --git a/apps/desktop/src/renderer/styles/workhub.css b/apps/desktop/src/renderer/styles/workhub.css index 631bcd13ad..5bd40e0efd 100644 --- a/apps/desktop/src/renderer/styles/workhub.css +++ b/apps/desktop/src/renderer/styles/workhub.css @@ -78,6 +78,72 @@ flex: 1 1 auto; } +.workhub-body { + display: grid; + width: 100%; + min-width: 0; + grid-template-columns: minmax(180px, 220px) minmax( + 0, + var(--maka-reading-measure) + ) minmax(180px, 220px); + gap: var(--space-6, 24px); + justify-content: center; +} + +.workhub-conversation-shell { + width: 100%; + min-width: 0; + grid-column: 2; +} + +.workhub-anchor-rail { + position: sticky; + top: var(--space-3, 12px); + align-self: start; + max-height: calc(100vh - 180px); + padding: var(--space-3, 12px) 0; + overflow-y: auto; +} + +.workhub-anchor-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + padding-bottom: 8px; + border-bottom: 1px solid var(--color-border-subtle, var(--border)); +} + +.workhub-anchor-heading strong { + font-size: var(--font-size-body-sm, 12px); +} + +.workhub-anchor-heading span, +.workhub-anchor-empty { + color: var(--color-text-secondary, var(--muted-foreground)); + font-size: var(--font-size-body-xs, 12px); +} + +.workhub-filters { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 8px 0; +} + +.workhub-filter-button { + font-size: var(--font-size-body-xs, 12px); +} + +.workhub-anchors { + display: flex; + flex-direction: column; +} + +.workhub-anchor-empty { + margin: 0; + padding: 12px 0; +} + .workhub-empty { box-sizing: border-box; padding: 72px 16px; @@ -248,3 +314,32 @@ padding-inline: 4px; } } + +@media (max-width: 1240px) { + .workhub-body { + display: block; + } + + .workhub-anchor-rail { + position: static; + width: min(var(--maka-reading-measure), 100%); + max-height: none; + margin-inline: auto; + padding: 12px 16px 8px; + overflow: hidden; + border-bottom: 1px solid var(--color-border-subtle, var(--border)); + } + + .workhub-filters { + flex-wrap: nowrap; + overflow-x: auto; + } + + .workhub-anchors { + display: grid; + grid-auto-columns: minmax(168px, 1fr); + grid-auto-flow: column; + overflow-x: auto; + } + +} diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index c259514837..6eea15a9c6 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -24,11 +24,19 @@ */ import { + boundedWorkHubText, + createWorkHubR24RoutingStrategy, createWorkHubRoutePolicy, + boundedRoutingInput, + readWorkHubRoutingEvidence, + type WorkHubRoutePolicy, type WorkHubRouteEvidence, + type WorkHubRoutingStrategy, + type WorkHubRoutingStrategyId, type WorkHubStopClarificationReason, type WorkHubNamedActionRouteDecision, -} from './workhub-route-policy.js'; + WORKHUB_R24_ROUTING_STRATEGY_ID, +} from './features/workhub/index.js'; import type { OperationError, WorkHubCoordinationActInput, @@ -141,16 +149,14 @@ export type WorkHubDelegationLinkState = 'active' | 'superseded' | 'aborted' | ' const WORKHUB_TIMELINE_TEXT_LIMIT = 600; export function boundedWorkHubTimelineText(value: string): string { - const text = value.trim(); - const chars = Array.from(text); - return chars.length <= WORKHUB_TIMELINE_TEXT_LIMIT - ? text - : `${chars.slice(0, WORKHUB_TIMELINE_TEXT_LIMIT - 1).join('')}…`; + return boundedWorkHubText(value, WORKHUB_TIMELINE_TEXT_LIMIT); } export interface WorkHubProjection { sessions: WorkHubSessionSummary[]; turns: WorkHubProjectedTurn[]; + /** Current deterministic coordination focus; projection only, never authority. */ + focusSessionId?: string; } export interface WorkHubSubmitInput { @@ -170,8 +176,9 @@ export interface WorkHubReadInput { focus?: WorkHubSessionTarget; } -export const WORKHUB_ROUTING_STRATEGY_ID = 'wh-r2.4-session-context-continuity' as const; -export type WorkHubRoutingStrategyId = typeof WORKHUB_ROUTING_STRATEGY_ID; +/** @deprecated Prefer the versioned IDs exported by the WorkHub feature. */ +export const WORKHUB_ROUTING_STRATEGY_ID = WORKHUB_R24_ROUTING_STRATEGY_ID; +export type { WorkHubRoutingStrategyId } from './features/workhub/index.js'; export type WorkHubSubmission = ( | { @@ -264,7 +271,9 @@ export interface WorkHubController { read(input?: WorkHubReadInput): Promise; submit(input: WorkHubSubmitInput): Promise; openConversation( - handler: (turns: readonly WorkHubCoordinationTurn[]) => void, + handler: ( + turns: readonly WorkHubCoordinationTurn[], + ) => void, onError: (error: unknown) => void, ): Promise<{ close(): Promise }>; recordConversationTurn(input: { @@ -280,9 +289,12 @@ export interface WorkHubController { export function createWorkHubController(deps: { sessions: WorkHubSessionPort; coordination: WorkHubCoordinationPort; + routingStrategy?: WorkHubRoutingStrategy; }): WorkHubController { const { coordination } = deps; + const routingStrategy = deps.routingStrategy ?? createWorkHubR24RoutingStrategy(); let routePolicy = createWorkHubRoutePolicy(); + let routingTranscript: Array<{ userText: string; assistantText?: string }> = []; let focusReadVersion = 0; let pendingFocusReadVersion: number | undefined; const correctionFor = ( @@ -296,7 +308,7 @@ export function createWorkHubController(deps: { return { from, sourceActionId }; }; const reconcileFocus = ( - policy: ReturnType, + policy: WorkHubRoutePolicy, sessions: readonly WorkHubSessionFacts[], ) => { policy.initializeFocus(sessions @@ -306,7 +318,7 @@ export function createWorkHubController(deps: { }; const completeSubmission = ( input: WorkHubSubmitInput, - policy: ReturnType, + policy: WorkHubRoutePolicy, admitted: Extract< WorkHubCoordinationActResult, { disposition: 'delegate_existing' | 'create_new' | 'replace' } @@ -318,7 +330,7 @@ export function createWorkHubController(deps: { policy.rememberTarget(target); return { kind: 'submitted', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, + strategyId: routingStrategy.strategyId, requestId: input.requestId, target, turnId: admitted.targetTurnId, @@ -331,12 +343,13 @@ export function createWorkHubController(deps: { input: WorkHubSubmitInput, decision: WorkHubNamedActionRouteDecision, kind: 'resume' | 'stop', + strategyId: WorkHubRoutingStrategyId, ): Promise | undefined> => { if (decision.kind === 'not_requested') return undefined; if (decision.kind === 'clarification') { return { kind: 'clarification', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, + strategyId, requestId: input.requestId, text: input.text, options: [], @@ -352,7 +365,7 @@ export function createWorkHubController(deps: { if (kind === 'resume' && !resumesActionId) { return { kind: 'clarification', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, + strategyId, requestId: input.requestId, text: input.text, options: [], @@ -372,7 +385,7 @@ export function createWorkHubController(deps: { ...(kind === 'stop' ? { confirmation: { kind: 'user_stop' as const } } : {}), }); const result = { - strategyId: WORKHUB_ROUTING_STRATEGY_ID, + strategyId, requestId: input.requestId, target, }; @@ -400,7 +413,7 @@ export function createWorkHubController(deps: { ) { return { kind: 'clarification', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, + strategyId, requestId: input.requestId, text: input.text, options: [], @@ -417,7 +430,7 @@ export function createWorkHubController(deps: { } return { kind: 'clarification', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, + strategyId, requestId: input.requestId, text: input.text, options: [], @@ -477,6 +490,12 @@ export function createWorkHubController(deps: { handle = await coordination.open((turns) => { if (disposed) return; latestTurns = turns; + routingTranscript = turns.slice(-12).map((turn) => ({ + userText: boundedWorkHubTimelineText(turn.text), + ...(turn.result + ? { assistantText: boundedWorkHubTimelineText(turn.result) } + : {}), + })); generation += 1; // The atomic assignment is already durable acknowledgement, so emit // it immediately before enriching it with target-owned lifecycle. @@ -539,6 +558,7 @@ export function createWorkHubController(deps: { ) { reconcileFocus(readPolicy, facts); } + const focusSessionId = readPolicy.focusSnapshot().current?.sessionId; return { sessions: ordinary .map(({ kind: _kind, runningTurnIds: _runningTurnIds, ...session }) => session), @@ -546,6 +566,7 @@ export function createWorkHubController(deps: { // Ordinary Session transcripts remain routing evidence, never a // second WorkHub conversation source. turns: [], + ...(focusSessionId ? { focusSessionId } : {}), }; } finally { if (input?.focus && pendingFocusReadVersion === readFocusVersion) { @@ -562,13 +583,13 @@ export function createWorkHubController(deps: { text: input.text, sessions: ordinary, }); - const resume = await submitNamedDelegationAction(input, resumeDecision, 'resume'); + const resume = await submitNamedDelegationAction(input, resumeDecision, 'resume', routingStrategy.strategyId); if (resume) return resume; const stopDecision = submissionPolicy.resolveStop({ text: input.text, sessions: ordinary, }); - const stop = await submitNamedDelegationAction(input, stopDecision, 'stop'); + const stop = await submitNamedDelegationAction(input, stopDecision, 'stop', routingStrategy.strategyId); if (stop) return stop; const candidateSet = await coordination.candidates(); const candidateBySessionId = new Map( @@ -585,13 +606,34 @@ export function createWorkHubController(deps: { const routingEvidence = input.explicitTarget ? [] : await deps.sessions.routingEvidence(routable.map((session) => session.target)); - const decision = submissionPolicy.resolve({ + const routingInput = boundedRoutingInput({ text: input.text, sessions: routable, originPromptBySessionId: new Map( routingEvidence.map((entry) => [entry.target.sessionId, entry.originPrompt]), ), + candidateRefBySessionId: new Map( + candidateSet.candidates.map((candidate) => [candidate.sessionId, candidate.candidateRef]), + ), + coordinationTranscript: routingTranscript, + ...(input.explicitTarget ? { explicitTarget: input.explicitTarget } : {}), + }); + // Only Policy owns focus and produces proposals. Component output is evidence. + const decisionPolicy = submissionPolicy.snapshot(); + const evidence = input.explicitTarget ? undefined : await readWorkHubRoutingEvidence(routingStrategy, routingInput); + const decision = decisionPolicy.resolve({ + text: input.text, + // Every arm receives the same trusted Policy context. Model input + // limits must not hide a known Session from exact-name/correction rules. + sessions: routable, + originPromptBySessionId: new Map(routingEvidence.map((entry) => [entry.target.sessionId, entry.originPrompt])), ...(input.explicitTarget ? { explicitTarget: input.explicitTarget } : {}), + ...(evidence ? { interpretation: { + classification: evidence.classification, + resolution: evidence.resolution.kind, + recalledSessionIds: evidence.resolution.kind === 'none' ? [] : evidence.resolution.candidateRefs.flatMap((ref) => + [...routingInput.candidateRefBySessionId].filter(([, value]) => value === ref).map(([sessionId]) => sessionId)), + } } : {}), }); if (decision.kind === 'clarification') { const correction = decision.correctedFrom @@ -599,7 +641,7 @@ export function createWorkHubController(deps: { : undefined; return { kind: 'clarification', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, + strategyId: routingStrategy.strategyId, requestId: input.requestId, text: input.text, options: decision.options.map((session) => ({ @@ -619,7 +661,7 @@ export function createWorkHubController(deps: { }); return { kind: 'discussion', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, + strategyId: routingStrategy.strategyId, requestId: input.requestId, text: input.text, }; @@ -675,7 +717,7 @@ export function createWorkHubController(deps: { if (targetSession?.state === 'waiting_for_user' && !input.retryAction) { return { kind: 'waiting', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, + strategyId: routingStrategy.strategyId, requestId: input.requestId, text: input.text, target, diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 15e33b071d..db8dd711ce 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -41,6 +41,8 @@ import { WorkHubSendLease, type WorkHubSendAttempt, } from './workhub-send-lease.js'; +import { WorkHubNavigationRail, WorkHubPromptRail } from './features/workhub/index.js'; +import { getWorkHubRailCopy } from './locales/workhub-copy.js'; export interface WorkHubConversationTurn { requestId: string; @@ -226,8 +228,12 @@ export function WorkHubSurface(props: { onOpenSession(sessionId: string): void; }) { const copy = workHubCopy(props.locale); + const railCopy = getWorkHubRailCopy(props.locale); const [projection, setProjection] = useState({ sessions: [], turns: [] }); - const [coordinationTurns, setCoordinationTurns] = useState([]); + const [coordination, setCoordination] = useState<{ + readonly turns: readonly WorkHubCoordinationTurn[]; + readonly delegatedSessionIds: readonly string[]; + }>({ turns: [], delegatedSessionIds: [] }); const [turns, setTurns] = useState([]); const [pending, setPending] = useState(false); const [initialLoadSettled, setInitialLoadSettled] = useState(false); @@ -274,7 +280,12 @@ export function WorkHubSurface(props: { void props.controller.openConversation( (next) => { if (disposed) return; - setCoordinationTurns(next); + setCoordination({ + turns: next, + delegatedSessionIds: [...next].sort((left, right) => right.updatedAt - left.updatedAt).flatMap( + (turn) => turn.assignment?.linkState === 'active' ? [turn.assignment.targetSessionId] : [], + ), + }); setConversationReady(true); setConversationError(false); }, @@ -365,7 +376,7 @@ export function WorkHubSurface(props: { }, }); }, [conversationReady, initialLoadSettled, route, routeGate, sendLease]); - const visible = visibleWorkHubConversation(coordinationTurns, turns); + const visible = visibleWorkHubConversation(coordination.turns, turns); const visibleCoordinationTurns = visible.coordination; const visibleLocalTurns = visible.local; const conversationEmpty = visibleCoordinationTurns.length === 0 && visibleLocalTurns.length === 0; @@ -396,12 +407,33 @@ export function WorkHubSurface(props: { : copy.loading} -
- +
+ + +
+ ({ + turnId: `workhub-message-${turn.messageId}`, + label: turn.text, + reply: turn.result, + })), + ...visibleLocalTurns.map((turn) => ({ + turnId: `workhub-request-${turn.requestId}`, + label: turn.text, + })), + ]} /> + {!surfaceReady ? ( ) : conversationEmpty && !loadError && !conversationError ? ( @@ -456,7 +488,8 @@ export function WorkHubSurface(props: { ))}
)} - + +
@@ -574,6 +607,7 @@ export function WorkHubCoordinationTurnView(props: { : undefined; return ( + {turn.state === 'routing' ? (

{copy.routing}

) : turn.state === 'failed' ? ( @@ -775,6 +809,7 @@ function WorkHubTurnView(props: { } function WorkHubMessageFrame(props: { + anchorId: string; text: string; state: string; linkState?: WorkHubDelegationLinkState; @@ -784,6 +819,8 @@ function WorkHubMessageFrame(props: { return (
diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx index 610f5d637e..c447e788cb 100644 --- a/apps/desktop/stories/workhub.stories.tsx +++ b/apps/desktop/stories/workhub.stories.tsx @@ -18,7 +18,7 @@ */ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, waitFor } from 'storybook/test'; +import { expect, fn, userEvent, within, waitFor } from 'storybook/test'; import type { WorkHubController, WorkHubCoordinationTurn, @@ -101,15 +101,17 @@ function controller(turns: readonly WorkHubCoordinationTurn[]): WorkHubControlle }; } -function Surface(props: { turns: readonly WorkHubCoordinationTurn[] }) { +const openRailSession = fn(); + +function Surface(props: { turns: readonly WorkHubCoordinationTurn[]; onOpenSession?: (sessionId: string) => void; fixture?: WorkHubController }) { return (
{}} + onOpenSession={props.onOpenSession ?? (() => {})} />
@@ -152,3 +154,124 @@ export const SubmittedWorkKeepsTargetMetadataInside: Story = { expect(bubbleRadius).toBe(getComputedStyle(plate).borderTopLeftRadius); }, }; + +// Real path: the production WorkHubSurface derives the Rail from Session facts. +// Filtering and responsive geometry need a renderer, not an Electron/Host fixture. +const anchorRailPlay: NonNullable = async ({ canvasElement }) => { + openRailSession.mockClear(); + const canvas = within(canvasElement); + const rail = await canvas.findByRole('complementary', { name: '工作导航' }); + const navigation = within(rail); + await expect(await navigation.findByRole('button', { name: new RegExp(SESSION_NAME) })).toBeVisible(); + const sessionEntry = navigation.getByRole('button', { name: new RegExp(SESSION_NAME) }); + await userEvent.click(sessionEntry); + await expect(openRailSession).toHaveBeenCalledTimes(1); + await expect(openRailSession).toHaveBeenLastCalledWith('session-workhub-target'); + sessionEntry.focus(); + await userEvent.keyboard('{Enter}'); + await expect(openRailSession).toHaveBeenCalledTimes(2); + await expect(openRailSession).toHaveBeenLastCalledWith('session-workhub-target'); + await userEvent.click(navigation.getByRole('button', { name: '待处理' })); + await expect(navigation.getByText('此筛选下没有工作')).toBeVisible(); + await expect(navigation.queryByRole('button', { name: new RegExp(SESSION_NAME) })).toBeNull(); + await userEvent.click(navigation.getByRole('button', { name: '全部' })); + await expect(await navigation.findByRole('button', { name: new RegExp(SESSION_NAME) })).toBeVisible(); + const conversation = canvasElement.querySelector('.workhub-conversation-shell'); + const composer = canvasElement.querySelector('.workhub-surface .maka-composer-editor'); + if (!conversation || !composer) throw new Error('WorkHub conversation or composer missing'); + const railBox = rail.getBoundingClientRect(); + const conversationBox = conversation.getBoundingClientRect(); + const composerBox = composer.getBoundingClientRect(); + if (window.innerWidth <= 1240) { + expect(railBox.bottom).toBeLessThanOrEqual(conversationBox.top + 1); + } else { + expect(railBox.right).toBeLessThanOrEqual(conversationBox.left); + expect(Math.abs(composerBox.left + composerBox.width / 2 - + (conversationBox.left + conversationBox.width / 2))).toBeLessThanOrEqual(4); + } +}; + +export const AnchorRailFiltersAndReflows: Story = { + render: () => , + play: anchorRailPlay, +}; + +// The render smoke runner selects its narrow viewport from this story ID. +export const AnchorRailFiltersAndReflowsNarrow: Story = { + ...AnchorRailFiltersAndReflows, +}; + +// Production scroll container and message frames: enough real turns to require +// scrolling, with two messages sharing a Turn ID to exercise message identity. +const promptRailTurns: WorkHubCoordinationTurn[] = Array.from({ length: 14 }, (_, index) => ({ + messageId: `prompt-${index}`, + turnId: `conversation-${Math.floor(index / 2)}`, + text: `第 ${index + 1} 次讨论:支付回调的并发与重试`, + result: '已检查当前处理路径。需要同时覆盖重复投递、并发请求和失败后的重试,确认每个请求只产生一次业务变更。', + state: 'completed', + updatedAt: index, +})); + +let publishPromptTurns: (turns: readonly WorkHubCoordinationTurn[]) => void = () => {}; +const promptController: WorkHubController = { + ...controller(promptRailTurns), + openConversation: async (handler) => { + publishPromptTurns = handler; + handler(promptRailTurns); + return { close: async () => { publishPromptTurns = () => {}; } }; + }, +}; + +const promptRailPlay: NonNullable = async ({ canvasElement }) => { + const root = canvasElement.querySelector('[data-chat-scroll-container]'); + if (!root) throw new Error('WorkHub scroll container missing'); + await waitFor(() => expect(canvasElement.querySelectorAll('.maka-prompt-rail-tick')).toHaveLength(14)); + await waitFor(() => expect(canvasElement.querySelectorAll('.workhub-turn[data-turn-id]')).toHaveLength(14)); + const ticks = Array.from(canvasElement.querySelectorAll('.maka-prompt-rail-tick')); + const frames = Array.from(canvasElement.querySelectorAll('.workhub-turn[data-turn-id]')); + expect(new Set(frames.map((frame) => frame.dataset.turnId)).size).toBe(14); + expect(root.scrollHeight).toBeGreaterThan(root.clientHeight); + await userEvent.click(ticks[0]!); + await waitFor(() => { + expect(ticks[0]).toHaveAttribute('aria-current', 'true'); + expect(Math.abs(frames[0]!.getBoundingClientRect().top - root.getBoundingClientRect().top)).toBeLessThan(4); + }); + const navigation = within(await within(canvasElement).findByRole('complementary', { name: '工作导航' })); + await userEvent.click(navigation.getByRole('button', { name: '待处理' })); + expect(canvasElement.querySelectorAll('.maka-prompt-rail-tick')).toHaveLength(14); + await userEvent.click(navigation.getByRole('button', { name: '全部' })); + ticks[6]!.focus(); + await userEvent.keyboard('{Enter}'); + await waitFor(() => { + expect(ticks[6]).toHaveAttribute('aria-current', 'true'); + expect(Math.abs(frames[6]!.getBoundingClientRect().top - root.getBoundingClientRect().top)).toBeLessThan(4); + }); + // A reader wheel gesture releases the shared rail's short jump hold. + root.dispatchEvent(new WheelEvent('wheel', { deltaY: root.scrollHeight, bubbles: true })); + root.scrollTo({ top: root.scrollHeight, behavior: 'instant' }); + await waitFor(() => expect(ticks[13]).toHaveAttribute('aria-current', 'true')); + // Leave a middle prompt selected for visual evidence of the rail and target. + await userEvent.click(ticks[6]!); + await waitFor(() => expect(ticks[6]).toHaveAttribute('aria-current', 'true')); + // Simulate ordinary Coordination transcript updates while the reader is + // inspecting an earlier message: growth must not pull them back to the tail. + for (let chunk = 1; chunk <= 3; chunk += 1) { + publishPromptTurns(promptRailTurns.map((turn, index) => index === 13 + ? { ...turn, state: 'running', result: `${turn.result}\n${'新增流式结果。'.repeat(chunk * 80)}` } + : turn)); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + await waitFor(() => { + expect(ticks[6]).toHaveAttribute('aria-current', 'true'); + expect(Math.abs(frames[6]!.getBoundingClientRect().top - root.getBoundingClientRect().top)).toBeLessThan(4); + }); + } +}; + +export const ConversationPromptAnchors: Story = { + render: () => , + play: promptRailPlay, +}; + +export const ConversationPromptAnchorsNarrow: Story = { + ...ConversationPromptAnchors, +}; diff --git a/docs/architecture/workhub-coordination-session-adr.md b/docs/architecture/workhub-coordination-session-adr.md index 93522fc3d4..8b9a848019 100644 --- a/docs/architecture/workhub-coordination-session-adr.md +++ b/docs/architecture/workhub-coordination-session-adr.md @@ -95,6 +95,24 @@ trusted user text, claims the source delegation in Coordination transcript order and rejects any later competing replacement intent. Neither a model nor a routing policy can directly authorize a write, Stop, or expansion of execution authority. +Routing experiments replace Action Intent classification and/or Session Resolver +recall behind the fixed Action Policy and unchanged Action Gate. A strategy names +one Intent component and one Resolver component; it has no proposal-producing +`resolve()` method and owns no visit focus. R2.4 pairs deterministic components; +R3-A pairs model-assisted intent with model-ranked recall; R3-B pairs model-assisted +intent with deterministic recall. These are experiment configurations, not separate +policy implementations or a production model rollout. + +Intent output contains no target. Resolver output contains only ranked or ambiguous +opaque candidate references, or no match; it cannot return creation or a disposition. +The controller shares one bounded candidate context across arms; deterministic +components retain full request text, while model adapters bound text at the model +call boundary. The controller passes validated evidence through the same Policy with the same trusted Session snapshot. +A model recall budget does not hide known Sessions from exact-name or correction +rules in that fixed Policy. Policy retains trusted-text creation, +ambiguity, correction and focus constraints. Model ranking alone cannot authorize +work, and every resulting proposal still goes through the Host-owned Gate. + ## Delegation links rather than copies transcripts A delegation persists only a bounded link between the coordination and execution diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index d27d6b438e..ca5ab66316 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.2` (194 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 254 files — blocker 0, reimplementation 0, polish 1, aligned 253. +**Totals:** 256 files — blocker 0, reimplementation 0, polish 1, aligned 255. ## Exclusions (explicit) @@ -97,6 +97,8 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx` | shell-chrome-or-panel | Card, ResizeHandle, Spinner | aligned — uses Astryx (Card, ResizeHandle, Spinner) | aligned | | `apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx` | shell-chrome-or-panel | Badge, Card, DropdownMenu, DropdownMenuItem, Heading, Icon, Kbd, List, ListItem, Section, Spinner, Tab, TabList | aligned — uses Astryx (Badge, Card, DropdownMenu, DropdownMenuItem, Heading, Icon, Kbd, List) | aligned | | `apps/desktop/src/renderer/features/workbar/ui/workbar-toggle.tsx` | shell-chrome-or-panel | Icon, IconButton, Tooltip | aligned — uses Astryx (Icon, IconButton, Tooltip) | aligned | +| `apps/desktop/src/renderer/features/workhub/ui/workhub-navigation-rail.tsx` | other | Button, List, ListItem, StatusDot | aligned — uses Astryx (Button, List, ListItem, StatusDot) | aligned | +| `apps/desktop/src/renderer/features/workhub/ui/workhub-prompt-rail.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/keyboard-help.tsx` | dialog-overlay | Dialog, DialogHeader, Heading, Kbd, Layout, LayoutContent | aligned — uses Astryx (Dialog, DialogHeader, Heading, Kbd, Layout, LayoutContent) | aligned | | `apps/desktop/src/renderer/live-turn-reconciler.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/maka-tokens.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index aac1debbae..fd44277143 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -68,6 +68,8 @@ apps/desktop/src/renderer/features/workbar/ui/side-chat-close-confirmation.tsx apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx apps/desktop/src/renderer/features/workbar/ui/workbar-toggle.tsx +apps/desktop/src/renderer/features/workhub/ui/workhub-navigation-rail.tsx +apps/desktop/src/renderer/features/workhub/ui/workhub-prompt-rail.tsx apps/desktop/src/renderer/keyboard-help.tsx apps/desktop/src/renderer/live-turn-reconciler.tsx apps/desktop/src/renderer/maka-tokens.css diff --git a/docs/images/pr/workhub-prompt-anchors-narrow-dark.png b/docs/images/pr/workhub-prompt-anchors-narrow-dark.png new file mode 100644 index 0000000000..07a19d42ba Binary files /dev/null and b/docs/images/pr/workhub-prompt-anchors-narrow-dark.png differ diff --git a/docs/images/pr/workhub-prompt-anchors-wide-light.png b/docs/images/pr/workhub-prompt-anchors-wide-light.png new file mode 100644 index 0000000000..a72b8c19ee Binary files /dev/null and b/docs/images/pr/workhub-prompt-anchors-wide-light.png differ diff --git a/docs/images/pr/workhub-rail-after-narrow-dark.png b/docs/images/pr/workhub-rail-after-narrow-dark.png new file mode 100644 index 0000000000..c6aed11656 Binary files /dev/null and b/docs/images/pr/workhub-rail-after-narrow-dark.png differ diff --git a/docs/images/pr/workhub-rail-after-wide-light.png b/docs/images/pr/workhub-rail-after-wide-light.png new file mode 100644 index 0000000000..58a8db3840 Binary files /dev/null and b/docs/images/pr/workhub-rail-after-wide-light.png differ diff --git a/docs/images/pr/workhub-rail-before-narrow-dark.png b/docs/images/pr/workhub-rail-before-narrow-dark.png new file mode 100644 index 0000000000..94df1a4e8c Binary files /dev/null and b/docs/images/pr/workhub-rail-before-narrow-dark.png differ diff --git a/docs/images/pr/workhub-rail-before-wide-light.png b/docs/images/pr/workhub-rail-before-wide-light.png new file mode 100644 index 0000000000..bcbcd56827 Binary files /dev/null and b/docs/images/pr/workhub-rail-before-wide-light.png differ diff --git a/docs/workhub-domain-language.md b/docs/workhub-domain-language.md index d54605ac26..0ae8b9ac41 100644 --- a/docs/workhub-domain-language.md +++ b/docs/workhub-domain-language.md @@ -192,10 +192,30 @@ it is not the final architecture or authority boundary of WorkHub. | --- | --- | | Action Intent | [`workhub-creation-intent.ts`](../packages/core/src/workhub-creation-intent.ts) | | Session Resolver | [`workhub-session-resolver.ts`](../packages/core/src/workhub-session-resolver.ts) | -| Action Policy | [`workhub-route-policy.ts`](../apps/desktop/src/renderer/workhub-route-policy.ts) | +| Action Policy | [`route-policy.ts`](../apps/desktop/src/renderer/features/workhub/model/route-policy.ts) | | Action Proposal | [`workhub-coordination.ts`](../packages/runtime-host/src/protocol/workhub-coordination.ts) | | Action Gate | [`workhub-coordination-action-gate.ts`](../packages/runtime-host/src/server/workhub-coordination-action-gate.ts) | | Projection | Coordination: [`workhub-coordination-port.ts`](../apps/desktop/src/renderer/workhub-coordination-port.ts); ordinary Sessions: [`workhub-session-port.ts`](../apps/desktop/src/renderer/workhub-session-port.ts) | +**Routing strategy**: A named combination of one independently replaceable +Action Intent classifier and one independently replaceable Session Resolver. +It owns neither Action Policy nor Action Gate. Ordinary routing experiments use: + +| Configuration | Intent | Resolver | Policy / Gate | +| --- | --- | --- | --- | +| R2.4 | Deterministic | Deterministic | Shared and unchanged | +| R3-A | Model-assisted | Model-ranked candidates | Shared and unchanged | +| R3-B | Model-assisted | Deterministic | Shared and unchanged | + +Model Intent carries no target, and model recall carries no disposition or creation +request. The fixed Policy combines these advisory results with trusted input, +exact-name/related/focus rules, and action-specific constraints. Named stop/resume +retain their deterministic reference requirements and Host admission. Component +failures become uncertain evidence; no component can write or directly submit a +proposal. Each arm receives the same bounded candidate context and the same trusted Policy +snapshot. Deterministic components read the full request; model adapters bound text +only at the model call boundary. The model recall limit does not remove known Sessions from the fixed +Policy's exact-name and correction rules. Production still uses R2.4. + _Avoid_: copied execution transcripts, self-routing, a second Session/WorkHub storage substrate, or treating model/routing output as execution authority. diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 267a4c1fff..4a7b9e90e4 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -187,3 +187,5 @@ export { type SearchSource, type SearchableItem, } from '@astryxdesign/core'; + +export { PromptAnchorRail, type PromptAnchorRailTurn } from './prompt-anchor-rail.js';