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';