@tea-agent/loop-agent 0.40.0-next.4 → 0.40.0-next.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/executors/shell-executor.js +4 -1
  4. package/dist/infrastructure/harness/atomic-write.js +23 -0
  5. package/dist/worker/console/chat/chat-event-store.js +62 -4
  6. package/dist/worker/console/chat/context-panel.js +7 -0
  7. package/dist/worker/console/chat/deferred-turn.js +12 -0
  8. package/dist/worker/console/chat/pi-runtime.js +264 -16
  9. package/dist/worker/console/chat/provider-error.js +98 -0
  10. package/dist/worker/console/chat/routes.js +292 -29
  11. package/dist/worker/console/chat/session-stats.js +179 -0
  12. package/dist/worker/console/chat/session-store.js +93 -63
  13. package/dist/worker/console/chat/todos.js +115 -0
  14. package/dist/worker/console/chat/tool-preview.js +42 -0
  15. package/dist/worker/console/chat/turn-process.js +20 -55
  16. package/dist/worker/console/chat/user-questions.js +266 -0
  17. package/dist/worker/console/chat/workspace-landing.js +14 -0
  18. package/dist/worker/console/native-directory-picker.js +13 -0
  19. package/dist/worker/console/routes.js +309 -1
  20. package/dist/worker/console/security.js +4 -4
  21. package/dist/worker/console/server.js +529 -186
  22. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-CTx1TrNa.js → abnfDiagram-N423BO3Z-DGt5TRsk.js} +1 -1
  23. package/dist/worker/console/static/assets/{arc-B4i9cVxX.js → arc-DKBSJsXm.js} +1 -1
  24. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-BAXE8_Rl.js → architectureDiagram-T3A2C74G-BnhooRBs.js} +1 -1
  25. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-C5Xh59at.js → blockDiagram-VBNYF7ZC-Bm9qkrJP.js} +1 -1
  26. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B_asOzAu.js → c4Diagram-5PPSVZJV-B2cFh4bO.js} +1 -1
  27. package/dist/worker/console/static/assets/channel-DSgFX07x.js +1 -0
  28. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-wRO_iRNc.js → chunk-2GRJ4B5K-DhvWRI03.js} +1 -1
  29. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-ByOaSh_G.js → chunk-2Q5K7J3B-chzTP_2w.js} +1 -1
  30. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DjLzudan.js → chunk-5RXB4S5H-SdWIR12m.js} +1 -1
  31. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-JCNGkXYT.js → chunk-5VM5RSS4-C2-8Eo_A.js} +1 -1
  32. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-BhrPXXMZ.js → chunk-6Q2QTUOP-DvbE1yQW.js} +1 -1
  33. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-TJ5JuEBT.js → chunk-GF5L2VYU-CEenyPTz.js} +1 -1
  34. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-VVthn_mD.js → chunk-JWPE2WC7-CcaZrq0k.js} +1 -1
  35. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-D60-Mny4.js → chunk-KBJHAD2P-FGngOTto.js} +1 -1
  36. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-zXv7aVye.js → chunk-RYQCIY6F-jT_yX3sf.js} +1 -1
  37. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-By9VQoi1.js → chunk-XXDRQBXY-CjE9LQOM.js} +1 -1
  38. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-unJObRls.js +1 -0
  39. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-unJObRls.js +1 -0
  40. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-C4zQLe-M.js → cose-bilkent-JH36ORCC-GQWns1tb.js} +1 -1
  41. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-wh5N2ReN.js → cynefin-VYW2F7L2-CGRpxWNJ.js} +1 -1
  42. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Dlu5wIqs.js → cynefinDiagram-MW4NZA55-Cd3uFGE4.js} +1 -1
  43. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CrDgthc6.js → dagre-VZM6K2ZE-DmDHuUW0.js} +1 -1
  44. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-zN1clg27.js → diagram-7IWD3JNH-kAr--hF3.js} +1 -1
  45. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BN-o7J7-.js → diagram-B4RE2ZJO-woahL2Oh.js} +1 -1
  46. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-C8SuPt0d.js → diagram-LBJQPF4R-7syNVaNf.js} +1 -1
  47. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BoizuHbZ.js → diagram-Q27KOJAE-BELPXiaY.js} +1 -1
  48. package/dist/worker/console/static/assets/{diagram-UB23O5K3-BzUzXixG.js → diagram-UB23O5K3-CrPa4aXi.js} +1 -1
  49. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Ev3lA4Cf.js → ebnfDiagram-BXEA7PRR-B48u2VVe.js} +1 -1
  50. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-GALUzRbs.js → erDiagram-JOGREHBK-DhBTfwTF.js} +1 -1
  51. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CZJN65Sb.js → flowDiagram-UKHOOZJN-CAeZ5nDU.js} +1 -1
  52. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-JLB9OESl.js → ganttDiagram-PKOTCBZU-CkIFeFLN.js} +1 -1
  53. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CcvCr4GD.js → gitGraphDiagram-DS77QQ5N-t0HGYDel.js} +1 -1
  54. package/dist/worker/console/static/assets/index-B4l5ag79.js +407 -0
  55. package/dist/worker/console/static/assets/index-CwVHWB71.css +1 -0
  56. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-HG5z7aVg.js → infoDiagram-6WML65LV-WM0HZhe4.js} +1 -1
  57. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BHEu9chR.js → ishikawaDiagram-WSZJBQD7-CV5byHe0.js} +1 -1
  58. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-Cp_Up8-a.js → journeyDiagram-NVQOT4AX-lIyWkgnw.js} +1 -1
  59. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-DvVeY-em.js → kanban-definition-27J2QSJJ-BIH4YNRY.js} +1 -1
  60. package/dist/worker/console/static/assets/{linear-aGmcjgTn.js → linear-lQ0ZGrWc.js} +1 -1
  61. package/dist/worker/console/static/assets/{mermaid.core-Biyo-tqA.js → mermaid.core-BYdXtke8.js} +5 -5
  62. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-DyxY2pIb.js → mindmap-definition-FAOFIHXS-k93vkIa4.js} +1 -1
  63. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-CkdGuc50.js → pegDiagram-VL7TDLO6-Bm6AqvSy.js} +1 -1
  64. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-NNQI9xVR.js → pieDiagram-7S7Q4E2Y-DnJW6Xpf.js} +1 -1
  65. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-PAJkuz_G.js → quadrantDiagram-CIZ2JOQS-CMzm4ObL.js} +1 -1
  66. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-ZwrQod0O.js → railroadDiagram-AXF67PYL-fwFF5dFQ.js} +1 -1
  67. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-D3ZfrzM2.js → requirementDiagram-LRYGKXZP-tzl9VDUc.js} +1 -1
  68. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-DDGFmWk5.js → sankeyDiagram-W5VNT64P-B26V09DD.js} +1 -1
  69. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DO6mmXTA.js → sequenceDiagram-SI44F4Z6-D2npyCeP.js} +1 -1
  70. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DMbCQiJt.js → sizeCapture-X5ZJPWSS-CTJtkRU2.js} +1 -1
  71. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-Di_YUEfC.js → stateDiagram-OKZ733FA-B8lk4Lzu.js} +1 -1
  72. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BmOcMTJE.js +1 -0
  73. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-Bn2PphNJ.js → swimlanes-SLNWSIFB-C3yP53hH.js} +2 -2
  74. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Beah-dq0.js +8 -0
  75. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DPh8pRsp.js → timeline-definition-Z64GVDOM-Bck7xjhe.js} +1 -1
  76. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-GMfl26pW.js → vennDiagram-T6HMQDX7-CCntr1w7.js} +1 -1
  77. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DTlmVrmF.js → wardleyDiagram-T6FBY63Y-DsCqCBZz.js} +1 -1
  78. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-YGBjKAS5.js → xychartDiagram-ELKLHX3M-Br2l90OM.js} +1 -1
  79. package/dist/worker/console/static/index.html +2 -2
  80. package/dist/worker/console/static-src/app/console-types.js +29 -4
  81. package/dist/worker/console/static-src/app/useConsoleShell.js +30 -12
  82. package/dist/worker/console/static-src/app/useOperatorActions.js +36 -6
  83. package/dist/worker/console/static-src/app/useRecoveryConsole.js +10 -11
  84. package/dist/worker/console/static-src/app/useRunProgress.js +2 -1
  85. package/dist/worker/console/static-src/app/useTaskWizard.js +2 -2
  86. package/dist/worker/console/static-src/night/useNightBoard.js +8 -5
  87. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +140 -8
  88. package/dist/worker/console/static-src/operator-chat/details-dag-actions.js +122 -0
  89. package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
  90. package/dist/worker/console/static-src/operator-chat/runtime-selection-labels.js +27 -0
  91. package/dist/worker/console/static-src/operator-chat/turn-submission.js +3 -2
  92. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +168 -72
  93. package/dist/worker/console/static-src/operator-chat/useChatStream.js +78 -17
  94. package/dist/worker/console/static-src/operator-chat/useChatThread.js +184 -4
  95. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +63 -9
  96. package/dist/worker/console/static-src/operator-chat/useWorkspaceBrowserGate.js +4 -0
  97. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +3 -3
  98. package/dist/worker/console/static-src/shell/useWorkspaces.js +299 -0
  99. package/dist/worker/console/static-src/shell/workspace-route.js +337 -0
  100. package/dist/worker/console/workspace-context.js +234 -0
  101. package/dist/worker/console/workspace-registry.js +214 -0
  102. package/dist/worker/observe/server.js +4 -0
  103. package/dist/worker/observe/static/api.js +26 -6
  104. package/dist/worker/observe/static/app.js +18 -70
  105. package/dist/worker/observe/static/index.html +1 -1
  106. package/dist/worker/observe/static/inspect-workspace.js +307 -0
  107. package/dist/worker/observe/static/operator-chrome.css +145 -20
  108. package/dist/worker/observe/static/operator-chrome.d.ts +20 -3
  109. package/dist/worker/observe/static/operator-chrome.js +330 -100
  110. package/dist/worker/observe/static/router.d.ts +46 -0
  111. package/dist/worker/observe/static/router.js +25 -1
  112. package/dist/worker/observe/static/state.d.ts +50 -0
  113. package/dist/worker/observe/static/state.js +172 -0
  114. package/dist/worker/observe/static/views/dag-inspector.js +3 -2
  115. package/dist/worker/observe/static/views/dag.js +2 -1
  116. package/dist/worker/observe/static/views/dashboard.js +6 -4
  117. package/dist/worker/observe/static/views/night.js +2 -2
  118. package/dist/worker/observe/static/views/pool.js +1 -0
  119. package/dist/worker/observe/static/views/run.js +3 -1
  120. package/dist/worker/observe/static/views/task.js +1 -1
  121. package/dist/workflows/dag/backend-test-pytest-collection.js +11 -10
  122. package/dist/workflows/dag/interrupt-request.js +3 -3
  123. package/dist/workflows/dag/lifecycle.js +3 -3
  124. package/docs/README.md +1 -0
  125. package/docs/operations/local-development-environment.md +21 -0
  126. package/harness.json +3 -3
  127. package/package.json +3 -1
  128. package/dist/worker/console/static/assets/channel-Dj_4Moei.js +0 -1
  129. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C0YYAyCa.js +0 -1
  130. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C0YYAyCa.js +0 -1
  131. package/dist/worker/console/static/assets/index-CW88Fjbw.js +0 -406
  132. package/dist/worker/console/static/assets/index-DP0ZJl39.css +0 -1
  133. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DrdDvela.js +0 -1
  134. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DPkEAULl.js +0 -8
@@ -12,18 +12,17 @@
12
12
  */
13
13
 
14
14
  export const OPERATE_TABS = Object.freeze([
15
- { id: "chat", label: "对话", href: "#/chat" },
16
- { id: "tasks", label: "任务", href: "#/tasks" },
17
- { id: "recovery", label: "恢复", href: "#/recovery" },
18
- { id: "night", label: "夜间", href: "#/night" },
19
- { id: "models", label: "模型与认证", href: "#/models" },
20
- { id: "settings", label: "设置", href: "#/settings" },
15
+ { id: "tasks", label: "任务", href: "/#/operate/tasks" },
16
+ { id: "recovery", label: "恢复", href: "/#/operate/recovery" },
17
+ { id: "night", label: "夜间", href: "/#/operate/night" },
18
+ { id: "models", label: "模型与认证", href: "/#/operate/models" },
19
+ { id: "settings", label: "设置", href: "/#/operate/settings" },
21
20
  ]);
22
21
 
23
22
  export const INSPECT_TABS = Object.freeze([
24
- { id: "dashboard", label: "总览", href: "#/" },
25
- { id: "pool", label: "资源池", href: "#/pool" },
26
- { id: "failures", label: "异常 Task", href: "#/failures" },
23
+ { id: "dashboard", label: "总览", href: "/#/inspect" },
24
+ { id: "pool", label: "资源池", href: "/#/inspect/pool" },
25
+ { id: "failures", label: "异常 Task", href: "/#/inspect/failures" },
27
26
  ]);
28
27
 
29
28
  export const READINESS_LABELS = Object.freeze({
@@ -45,14 +44,39 @@ const CONTEXT_KIND_LABELS = Object.freeze({
45
44
  /** 支持返回操作侧的对象视图 → 返回落地工作区。v1 仅接通 recovery 闭环。 */
46
45
  const RETURNABLE_VIEWS = new Set(["dag", "run"]);
47
46
 
47
+ export function parseInspectWorkspaceId(hashValue) {
48
+ const { query } = splitHash(hashValue);
49
+ if (!query) return null;
50
+ const id = query.get("workspaceId")?.trim();
51
+ return id || null;
52
+ }
53
+
54
+ export function withWorkspaceQuery(href, workspaceId) {
55
+ const raw = String(href ?? "");
56
+ if (!workspaceId) return raw;
57
+ const qIndex = raw.indexOf("?");
58
+ if (qIndex < 0) return `${raw}?workspaceId=${encodeURIComponent(workspaceId)}`;
59
+ const params = new URLSearchParams(raw.slice(qIndex + 1));
60
+ if (params.get("workspaceId") === workspaceId) return raw;
61
+ params.set("workspaceId", workspaceId);
62
+ return `${raw.slice(0, qIndex)}?${params.toString()}`;
63
+ }
64
+
48
65
  function splitHash(hashValue) {
49
66
  const raw = String(hashValue ?? "").replace(/^#/, "");
50
67
  const qIndex = raw.indexOf("?");
51
- const path = (qIndex >= 0 ? raw.slice(0, qIndex) : raw) || "/";
68
+ const pathPart = (qIndex >= 0 ? raw.slice(0, qIndex) : raw) || "/";
69
+ const path = pathPart.startsWith("/") ? pathPart : `/${pathPart}`;
52
70
  const query = qIndex >= 0 ? new URLSearchParams(raw.slice(qIndex + 1)) : null;
53
71
  return { path, query };
54
72
  }
55
73
 
74
+ function inspectInternalPath(path) {
75
+ if (path === "/inspect" || path === "/inspect/") return "/";
76
+ if (path.startsWith("/inspect/")) return path.slice("/inspect".length) || "/";
77
+ return path;
78
+ }
79
+
56
80
  function decode(value) {
57
81
  try {
58
82
  return decodeURIComponent(value);
@@ -64,7 +88,8 @@ function decode(value) {
64
88
  /** 与 router.js 同形的最小对象视图解析(只读 chrome 用,不替代路由)。 */
65
89
  export function parseInspectObjectView(hashValue) {
66
90
  const { path } = splitHash(hashValue);
67
- const match = /^\/(dag|run|task|feature|batch|night)\/([^/]+)$/.exec(path);
91
+ const internal = inspectInternalPath(path);
92
+ const match = /^\/(dag|run|task|feature|batch|night)\/([^/]+)$/.exec(internal);
68
93
  if (!match) return null;
69
94
  const id = decode(match[2]);
70
95
  if (!id) return null;
@@ -79,10 +104,15 @@ export function parseInspectObjectView(hashValue) {
79
104
  */
80
105
  export function activeInspectTabHref(hashValue) {
81
106
  const { path } = splitHash(hashValue);
82
- if (path === "/" || path === "") return "#/";
83
- if (path === "/pool" || path.startsWith("/task/")) return "#/pool";
84
- if (path === "/failures") return "#/failures";
85
- if (path.startsWith("/feature/")) return "#/";
107
+ const internal = inspectInternalPath(path);
108
+ if (internal === "/" || internal === "" || internal === "/inspect") {
109
+ return "/#/inspect";
110
+ }
111
+ if (internal === "/pool" || internal.startsWith("/task/")) {
112
+ return "/#/inspect/pool";
113
+ }
114
+ if (internal === "/failures") return "/#/inspect/failures";
115
+ if (internal.startsWith("/feature/")) return "/#/inspect";
86
116
  return null;
87
117
  }
88
118
 
@@ -104,7 +134,7 @@ export function parseInspectContext(hashValue) {
104
134
  id: objectView.id,
105
135
  from,
106
136
  kindLabel: CONTEXT_KIND_LABELS[objectView.view] ?? objectView.view,
107
- returnHref: `/#/recovery${suffix ? `?${suffix}` : ""}`,
137
+ returnHref: `/#/operate/recovery${suffix ? `?${suffix}` : ""}`,
108
138
  returnLabel: "返回 恢复",
109
139
  };
110
140
  }
@@ -113,20 +143,19 @@ export function parseInspectContext(hashValue) {
113
143
  export function resolveOperateHrefFromInspectHash(hashValue) {
114
144
  const objectView = parseInspectObjectView(hashValue);
115
145
  if (objectView?.view === "dag") {
116
- return `/#/recovery?dagRunId=${encodeURIComponent(objectView.id)}`;
146
+ return `/#/operate/recovery?dagRunId=${encodeURIComponent(objectView.id)}`;
117
147
  }
118
- // 2026-08-16: 无持有对象时默认落到对话页(与 Console 默认 landing 一致)。
119
- return "/#/chat";
148
+ return "/#/operate/tasks";
120
149
  }
121
150
 
122
151
  /** Inspect DAG 详情 → Operate Recovery 协作中止深链(只改导航,不发 mutation)。 */
123
152
  export function buildOperateInterruptHref(dagRunId) {
124
- return `/#/recovery?dagRunId=${encodeURIComponent(dagRunId)}&intent=interrupt`;
153
+ return `/#/operate/recovery?dagRunId=${encodeURIComponent(dagRunId)}&intent=interrupt`;
125
154
  }
126
155
 
127
156
  /** 操作侧 → Inspect 的 canonical 深链(可携带 from 以激活上下文 chip)。 */
128
157
  export function buildInspectDagHref(dagRunId, options = {}) {
129
- const base = `/inspect/#/dag/${encodeURIComponent(dagRunId)}`;
158
+ const base = `/#/inspect/dag/${encodeURIComponent(dagRunId)}`;
130
159
  return options.from === "recovery" ? `${base}?from=recovery` : base;
131
160
  }
132
161
 
@@ -174,45 +203,76 @@ export function chromeStateFromHealth(body) {
174
203
  * 共用同一份模型结构,保证 DOM 契约一致。
175
204
  */
176
205
  export function buildOperatorChromeModel(config) {
177
- const mode = config.mode === "inspect" ? "inspect" : "operate";
178
- const tabs = (mode === "inspect" ? INSPECT_TABS : OPERATE_TABS).map(
179
- (tab) => ({
180
- ...tab,
181
- active: tab.href === (config.activeTabHref ?? null),
182
- }),
183
- );
206
+ const mode =
207
+ config.mode === "inspect"
208
+ ? "inspect"
209
+ : config.mode === "chat"
210
+ ? "chat"
211
+ : "operate";
212
+ const tabs = (
213
+ mode === "inspect"
214
+ ? INSPECT_TABS
215
+ : mode === "operate"
216
+ ? OPERATE_TABS
217
+ : []
218
+ ).map((tab) => ({
219
+ ...tab,
220
+ active:
221
+ tab.href ===
222
+ (String(config.activeTabHref ?? "").split("?")[0] || null),
223
+ }));
184
224
  const state = config.readinessState ?? "unknown";
225
+ const operateHref = config.operateHref ?? "/#/operate/tasks";
226
+ const inspectHref = config.inspectHref ?? "/#/inspect";
227
+ const activeWorkspaceId = config.workspaceSwitcher?.activeId ?? null;
185
228
  return {
186
229
  mode,
187
- homeHref: mode === "inspect" ? "/inspect/#/" : "/#/",
230
+ homeHref: withWorkspaceQuery(
231
+ mode === "inspect" ? "/#/inspect" : "/#/chat",
232
+ activeWorkspaceId,
233
+ ),
188
234
  brand: "Loop Agent",
189
235
  repo: config.repo ?? null,
236
+ workspaceSwitcher: config.workspaceSwitcher ?? null,
190
237
  modeButtons: [
238
+ {
239
+ id: "chat",
240
+ label: "对话",
241
+ href: config.chatHref ?? "/#/chat",
242
+ active: mode === "chat",
243
+ },
191
244
  {
192
245
  id: "operate",
193
246
  label: "操作",
194
- // 2026-08-16: 默认落到对话页,与 Console 默认 landing(chat)一致。
195
- href: config.operateHref ?? "/#/chat",
247
+ href: operateHref,
196
248
  active: mode === "operate",
197
249
  },
198
250
  {
199
251
  id: "inspect",
200
252
  label: "观测",
201
- href: config.inspectHref ?? "/inspect/#/",
253
+ href: inspectHref,
202
254
  active: mode === "inspect",
203
255
  },
204
256
  ],
205
257
  context: config.context ?? null,
206
258
  freshness: mode === "inspect" && config.freshness !== false,
259
+ statusVisible: config.statusVisible !== false,
207
260
  pi: {
208
261
  state,
209
262
  label: READINESS_LABELS[state] ?? state,
210
263
  href:
211
- config.readinessHref ?? (mode === "inspect" ? "/#/models" : "#/models"),
264
+ config.readinessHref ??
265
+ (mode === "inspect" ? "/#/operate/models" : "/#/operate/models"),
212
266
  title: config.readinessTitle ?? "",
213
267
  },
268
+ workspaceTabsVisible: tabs.length > 0,
214
269
  tabs,
215
- tabsAriaLabel: mode === "inspect" ? "观测看板" : "操作工作区",
270
+ tabsAriaLabel:
271
+ mode === "inspect"
272
+ ? "观测看板"
273
+ : mode === "operate"
274
+ ? "操作工作区"
275
+ : "对话工作区",
216
276
  };
217
277
  }
218
278
 
@@ -254,6 +314,17 @@ const ICONS = Object.freeze({
254
314
  ["path", { d: "M6 8.4v7.2M8.2 6.9 15.8 8" }],
255
315
  ],
256
316
  },
317
+ folder: {
318
+ size: 14,
319
+ parts: [
320
+ ["path", { d: "M3 6.5h6l2-2h4.5c1.4 0 2.5 1.1 2.5 2.5v9.5c0 1.4-1.1 2.5-2.5 2.5h-11C3.1 19 2 17.9 2 16.5V8c0-.8.4-1.5 1-1.5z" }],
321
+ ],
322
+ },
323
+ check: {
324
+ size: 12,
325
+ strokeWidth: 2.4,
326
+ parts: [["path", { d: "m5 12 4 4L19 6" }]],
327
+ },
257
328
  chevron: {
258
329
  size: 10,
259
330
  strokeWidth: 2.4,
@@ -366,10 +437,21 @@ export function mountOperatorChrome(root, config = {}) {
366
437
  function build(model) {
367
438
  freshnessText =
368
439
  root.querySelector("#header-refresh")?.textContent ?? freshnessText;
369
- root.textContent = "";
440
+ /* Keep the product-mode control connected while the surrounding chrome is
441
+ * rebuilt. In particular, the shared thumb must retain its DOM identity and
442
+ * previous transform so the browser can interpolate to the next mode instead
443
+ * of flashing a freshly-created indicator at the destination. */
444
+ const persistentModeNav = root.querySelector(".och-mode");
445
+ if (persistentModeNav) root.appendChild(persistentModeNav);
446
+ for (const child of [...root.children]) {
447
+ if (child !== persistentModeNav) child.remove();
448
+ }
370
449
  const chrome = el("div", "och");
371
450
  const inner = el("div", "och-inner");
372
451
  chrome.appendChild(inner);
452
+ /* Attach the new shell before measuring the persistent mode buttons. Moving
453
+ * the nav within the same connected root preserves its painted transform. */
454
+ root.appendChild(chrome);
373
455
 
374
456
  /* Band 1 */
375
457
  const global = el("div", "och-global");
@@ -386,7 +468,89 @@ export function mountOperatorChrome(root, config = {}) {
386
468
  brand.appendChild(el("span", null, model.brand));
387
469
  global.appendChild(brand);
388
470
 
389
- if (model.repo) {
471
+ if (model.workspaceSwitcher) {
472
+ const picker = el("div", "och-workspace-picker");
473
+ const items = Array.isArray(model.workspaceSwitcher.items)
474
+ ? model.workspaceSwitcher.items
475
+ : [];
476
+ const active = items.find(
477
+ (item) => item.workspaceId === model.workspaceSwitcher.activeId,
478
+ );
479
+ const repoBtn = el("button", "och-repo och-workspace-trigger");
480
+ repoBtn.type = "button";
481
+ repoBtn.setAttribute("aria-haspopup", "menu");
482
+ repoBtn.setAttribute("aria-controls", popoverId);
483
+ repoBtn.setAttribute("aria-expanded", String(popoverOpen));
484
+ repoBtn.setAttribute(
485
+ "aria-label",
486
+ active ? `切换工作区,当前 ${active.name}` : "切换工作区",
487
+ );
488
+ icon(repoBtn, "folder");
489
+ repoBtn.appendChild(
490
+ el(
491
+ "span",
492
+ "och-repo-name",
493
+ active?.name ?? (model.workspaceSwitcher.loading ? "加载中…" : "选择工作区"),
494
+ ),
495
+ );
496
+ icon(repoBtn, "chevron");
497
+ picker.appendChild(repoBtn);
498
+
499
+ const pop = el("div", "och-pop och-workspace-menu");
500
+ pop.id = popoverId;
501
+ pop.hidden = !popoverOpen;
502
+ pop.setAttribute("role", "menu");
503
+ pop.setAttribute("aria-label", "切换工作区");
504
+ pop.append(el("p", "och-pop-title", "工作区"));
505
+ if (items.length === 0) {
506
+ pop.append(
507
+ el(
508
+ "p",
509
+ "och-workspace-empty",
510
+ model.workspaceSwitcher.loading ? "正在加载工作区…" : "暂无工作区",
511
+ ),
512
+ );
513
+ }
514
+ for (const item of items) {
515
+ const option = el("button", "och-workspace-option");
516
+ option.type = "button";
517
+ option.setAttribute("role", "menuitemradio");
518
+ option.setAttribute(
519
+ "aria-checked",
520
+ String(item.workspaceId === model.workspaceSwitcher.activeId),
521
+ );
522
+ const copy = el("span", "och-workspace-option-copy");
523
+ copy.append(
524
+ el("strong", null, item.name),
525
+ el("small", null, item.displayPath || ""),
526
+ );
527
+ option.appendChild(copy);
528
+ if (item.workspaceId === model.workspaceSwitcher.activeId) {
529
+ icon(option, "check");
530
+ }
531
+ option.addEventListener("click", (event) => {
532
+ event.stopPropagation();
533
+ popoverOpen = false;
534
+ if (
535
+ item.workspaceId !== model.workspaceSwitcher.activeId &&
536
+ typeof currentConfig.onSelectWorkspace === "function"
537
+ ) {
538
+ currentConfig.onSelectWorkspace(item.workspaceId);
539
+ } else {
540
+ render();
541
+ }
542
+ });
543
+ pop.appendChild(option);
544
+ }
545
+ picker.appendChild(pop);
546
+ global.appendChild(picker);
547
+ repoBtn.addEventListener("click", (event) => {
548
+ event.stopPropagation();
549
+ popoverOpen = !popoverOpen;
550
+ repoBtn.setAttribute("aria-expanded", String(popoverOpen));
551
+ pop.hidden = !popoverOpen;
552
+ });
553
+ } else if (model.repo) {
390
554
  const repoBtn = el("button", "och-repo");
391
555
  repoBtn.type = "button";
392
556
  repoBtn.setAttribute("aria-haspopup", "dialog");
@@ -430,22 +594,61 @@ export function mountOperatorChrome(root, config = {}) {
430
594
  });
431
595
  }
432
596
 
433
- const modeNav = el("nav", "och-mode");
597
+ const modeNav = persistentModeNav ?? el("nav", "och-mode");
434
598
  modeNav.setAttribute("aria-label", "产品模式");
435
- for (const btn of model.modeButtons) {
436
- const a = el("a", `och-mode-btn${btn.active ? " och-on" : ""}`);
599
+ const modeIcons = { chat: "radar", operate: "zap", inspect: "eye" };
600
+ let buttons = [...modeNav.querySelectorAll(".och-mode-btn")];
601
+ if (buttons.length !== model.modeButtons.length) {
602
+ modeNav.replaceChildren();
603
+ buttons = model.modeButtons.map((btn) => {
604
+ const a = el("a", "och-mode-btn");
605
+ a.dataset.mode = btn.id;
606
+ // 2026-08-22: inline-flex 布局下裸文本节点不参与 name-from-content,
607
+ // a11y snapshot 序列化为无名 link;显式 aria-label 保证「操作/观测」
608
+ // 对 find/读屏稳定可断言(frontend-test passed 权威依赖可见 find)。
609
+ a.setAttribute("aria-label", btn.label);
610
+ a.appendChild(makeIcon(modeIcons[btn.id] ?? "eye"));
611
+ a.appendChild(document.createTextNode(btn.label));
612
+ modeNav.appendChild(a);
613
+ return a;
614
+ });
615
+ }
616
+ for (const [index, btn] of model.modeButtons.entries()) {
617
+ const a = buttons[index];
437
618
  a.href = btn.href;
438
619
  a.dataset.mode = btn.id;
439
- // 2026-08-22: inline-flex 布局下裸文本节点不参与 name-from-content,
440
- // a11y snapshot 序列化为无名 link;显式 aria-label 保证「操作/观测」
441
- // 对 find/读屏稳定可断言(frontend-test passed 权威依赖可见 find)。
442
- a.setAttribute("aria-label", btn.label);
620
+ a.classList.toggle("och-on", btn.active);
443
621
  if (btn.active) a.setAttribute("aria-current", "page");
444
- a.appendChild(makeIcon(btn.id === "operate" ? "zap" : "eye"));
445
- a.appendChild(document.createTextNode(btn.label));
446
- modeNav.appendChild(a);
622
+ else a.removeAttribute("aria-current");
447
623
  }
624
+ /* DSH 2026-08-23:椭圆选中态 + 共享滑块(160-220ms),同一实现覆盖
625
+ * React(Operate/Chat 壳)与 vanilla(Inspect)。reduced-motion 下无动画。 */
626
+ let thumb = modeNav.querySelector(".och-mode-thumb");
627
+ const firstMount = !thumb;
628
+ if (!thumb) {
629
+ thumb = el("span", "och-mode-thumb");
630
+ thumb.setAttribute("aria-hidden", "true");
631
+ modeNav.appendChild(thumb);
632
+ }
633
+ const positionThumb = (mode) => {
634
+ const target = buttons.find((button) => button.dataset.mode === mode);
635
+ if (!target) {
636
+ thumb.style.opacity = "0";
637
+ return;
638
+ }
639
+ thumb.style.opacity = "";
640
+ thumb.style.width = `${target.offsetWidth}px`;
641
+ thumb.style.height = `${target.offsetHeight}px`;
642
+ thumb.style.transform = `translate3d(${target.offsetLeft}px, ${target.offsetTop}px, 0)`;
643
+ };
448
644
  global.appendChild(modeNav);
645
+ positionThumb(model.mode);
646
+ if (firstMount) {
647
+ /* The initial placement must not animate in from the origin. Every later
648
+ * update reuses this ready thumb and therefore transitions immediately from
649
+ * its last painted transform. */
650
+ requestAnimationFrame(() => thumb.classList.add("is-ready"));
651
+ }
449
652
 
450
653
  if (model.context) {
451
654
  const chip = el("div", "och-context");
@@ -477,7 +680,7 @@ export function mountOperatorChrome(root, config = {}) {
477
680
 
478
681
  global.appendChild(el("span", "och-spacer"));
479
682
 
480
- if (model.freshness) {
683
+ if (model.statusVisible && model.freshness) {
481
684
  const fresh = el("span", "och-fresh");
482
685
  fresh.setAttribute("aria-label", "数据新鲜度");
483
686
  fresh.appendChild(el("span", "och-fresh-dot"));
@@ -489,6 +692,10 @@ export function mountOperatorChrome(root, config = {}) {
489
692
  global.appendChild(fresh);
490
693
  }
491
694
 
695
+ /* r4 AC-002:Chat 模式右侧保持干净 — 不渲染 repo 名与 Pi readiness 胶囊
696
+ * (多 Workspace 下 repo 身份归左栏行,readiness 归侧栏与横幅)。 */
697
+ const piHidden = !model.statusVisible || model.mode === "chat";
698
+ if (!piHidden) {
492
699
  const pi = el("a", "och-pi");
493
700
  pi.dataset.state = model.pi.state;
494
701
  pi.href = model.pi.href;
@@ -499,65 +706,69 @@ export function mountOperatorChrome(root, config = {}) {
499
706
  piState.textContent = model.pi.label;
500
707
  pi.appendChild(piState);
501
708
  global.appendChild(pi);
709
+ }
502
710
 
503
- /* 标签带折叠切换(收展两态同一按钮,位于全局带右端)。 */
504
- const fold = el("button", "och-workspace-fold");
505
- fold.type = "button";
506
- fold.setAttribute("aria-expanded", String(!workspaceCollapsed));
507
- fold.setAttribute("aria-controls", "och-workspace");
508
- const foldLabel = workspaceCollapsed ? "展开标签栏" : "收起标签栏";
509
- fold.title = foldLabel;
510
- fold.setAttribute("aria-label", foldLabel);
511
- if (workspaceCollapsed) fold.classList.add("is-folded");
512
- fold.appendChild(makeIcon("chevronUp"));
513
- fold.addEventListener("click", () => {
514
- workspaceCollapsed = !workspaceCollapsed;
515
- try {
516
- window.localStorage.setItem(
517
- OCH_WORKSPACE_COLLAPSED_KEY,
518
- workspaceCollapsed ? "1" : "0",
519
- );
520
- } catch {
521
- /* localStorage 不可用时仅保持内存态。 */
711
+ if (model.workspaceTabsVisible) {
712
+ /* 标签带折叠切换(收展两态同一按钮,位于全局带右端)。 */
713
+ const fold = el("button", "och-workspace-fold");
714
+ fold.type = "button";
715
+ fold.setAttribute("aria-expanded", String(!workspaceCollapsed));
716
+ fold.setAttribute("aria-controls", "och-workspace");
717
+ const foldLabel = workspaceCollapsed ? "展开标签栏" : "收起标签栏";
718
+ fold.title = foldLabel;
719
+ fold.setAttribute("aria-label", foldLabel);
720
+ if (workspaceCollapsed) fold.classList.add("is-folded");
721
+ fold.appendChild(makeIcon("chevronUp"));
722
+ fold.addEventListener("click", () => {
723
+ workspaceCollapsed = !workspaceCollapsed;
724
+ try {
725
+ window.localStorage.setItem(
726
+ OCH_WORKSPACE_COLLAPSED_KEY,
727
+ workspaceCollapsed ? "1" : "0",
728
+ );
729
+ } catch {
730
+ /* localStorage 不可用时仅保持内存态。 */
731
+ }
732
+ const ws = root.querySelector(".och-workspace");
733
+ const btn = root.querySelector(".och-workspace-fold");
734
+ if (ws) {
735
+ ws.classList.toggle(
736
+ "och-workspace-collapsed",
737
+ workspaceCollapsed,
738
+ );
739
+ }
740
+ if (btn) {
741
+ btn.classList.toggle("is-folded", workspaceCollapsed);
742
+ btn.setAttribute("aria-expanded", String(!workspaceCollapsed));
743
+ const label = workspaceCollapsed ? "展开标签栏" : "收起标签栏";
744
+ btn.title = label;
745
+ btn.setAttribute("aria-label", label);
746
+ }
747
+ });
748
+ global.appendChild(fold);
749
+
750
+ /* Band 2 */
751
+ const workspace = el("div", "och-workspace");
752
+ workspace.id = "och-workspace";
753
+ if (workspaceCollapsed) {
754
+ workspace.classList.add("och-workspace-collapsed");
522
755
  }
523
- const ws = root.querySelector(".och-workspace");
524
- const btn = root.querySelector(".och-workspace-fold");
525
- if (ws) {
526
- ws.classList.toggle(
527
- "och-workspace-collapsed",
528
- workspaceCollapsed,
756
+ inner.appendChild(workspace);
757
+ const tabs = el("nav", "och-tabs");
758
+ tabs.setAttribute("aria-label", model.tabsAriaLabel);
759
+ for (const tab of model.tabs) {
760
+ const a = el(
761
+ "a",
762
+ `och-tab${tab.active ? " och-tab-active" : ""}`,
763
+ tab.label,
529
764
  );
765
+ a.href = tab.href;
766
+ if (tab.active) a.setAttribute("aria-current", "page");
767
+ tabs.appendChild(a);
530
768
  }
531
- if (btn) {
532
- btn.classList.toggle("is-folded", workspaceCollapsed);
533
- btn.setAttribute("aria-expanded", String(!workspaceCollapsed));
534
- const label = workspaceCollapsed ? "展开标签栏" : "收起标签栏";
535
- btn.title = label;
536
- btn.setAttribute("aria-label", label);
537
- }
538
- });
539
- global.appendChild(fold);
540
-
541
- /* Band 2 */
542
- const workspace = el("div", "och-workspace");
543
- workspace.id = "och-workspace";
544
- if (workspaceCollapsed) workspace.classList.add("och-workspace-collapsed");
545
- inner.appendChild(workspace);
546
- const tabs = el("nav", "och-tabs");
547
- tabs.setAttribute("aria-label", model.tabsAriaLabel);
548
- for (const tab of model.tabs) {
549
- const a = el(
550
- "a",
551
- `och-tab${tab.active ? " och-tab-active" : ""}`,
552
- tab.label,
553
- );
554
- a.href = tab.href;
555
- if (tab.active) a.setAttribute("aria-current", "page");
556
- tabs.appendChild(a);
769
+ workspace.appendChild(tabs);
557
770
  }
558
- workspace.appendChild(tabs);
559
771
 
560
- root.appendChild(chrome);
561
772
  }
562
773
 
563
774
  function render() {
@@ -581,6 +792,25 @@ export function mountOperatorChrome(root, config = {}) {
581
792
  }
582
793
 
583
794
  function onDocumentClick(event) {
795
+ if (event.target instanceof Element) {
796
+ const link = event.target.closest("a");
797
+ if (
798
+ link &&
799
+ root.contains(link) &&
800
+ typeof currentConfig.onNavigate === "function" &&
801
+ event.button === 0 &&
802
+ !event.metaKey &&
803
+ !event.ctrlKey &&
804
+ !event.shiftKey &&
805
+ !event.altKey
806
+ ) {
807
+ const href = link.getAttribute("href") || "";
808
+ if (href.startsWith("#") || href.startsWith("/#") || href.startsWith("/inspect")) {
809
+ event.preventDefault();
810
+ currentConfig.onNavigate(href);
811
+ }
812
+ }
813
+ }
584
814
  if (!popoverOpen || !(event.target instanceof Node)) return;
585
815
  const repoBtn = root.querySelector(".och-repo");
586
816
  const pop = root.querySelector(".och-pop");
@@ -0,0 +1,46 @@
1
+ /** 与 router.js 对应的类型声明(供 vitest/TS 侧导入契约测试)。 */
2
+
3
+ export type InspectHashRoute = {
4
+ view: string;
5
+ batchRunId?: string;
6
+ workerRunId?: string;
7
+ dagRunId?: string;
8
+ featureId?: string;
9
+ taskId?: string;
10
+ scheduleId?: string;
11
+ page?: number;
12
+ pageSize?: number;
13
+ filters?: { status?: string; q?: string };
14
+ };
15
+
16
+ export function parseHashRoute(hashValue: string): InspectHashRoute;
17
+ export function parseRoute(): InspectHashRoute;
18
+ export function setNavigateHandler(
19
+ handler: ((href: string) => void) | null,
20
+ ): void;
21
+ export function inspectPathToCanonicalHref(path: string): string;
22
+ export function navigate(path: string): void;
23
+
24
+ export const DAG_HISTORY_DEFAULT_PAGE: number;
25
+ export const DAG_HISTORY_DEFAULT_PAGE_SIZE: number;
26
+ export const DAG_HISTORY_ALLOWED_PAGE_SIZES: readonly number[];
27
+
28
+ export function clampDagHistoryPageSize(value: unknown): number;
29
+ export function clampDagHistoryPage(
30
+ value: unknown,
31
+ totalPages?: number,
32
+ ): number;
33
+ export function parseDagHistoryQuery(hashValue: string): {
34
+ page: number;
35
+ pageSize: number;
36
+ };
37
+ export function buildDagHistoryHash(page: unknown, pageSize: unknown): string;
38
+ export function parseHashFilters(hashValue: string): {
39
+ status?: string;
40
+ q?: string;
41
+ };
42
+ export function parseHashRouteWithFilters(hashValue: string): InspectHashRoute;
43
+ export function buildHashPath(
44
+ path: string,
45
+ filters?: Record<string, unknown>,
46
+ ): string;
@@ -1,7 +1,10 @@
1
1
  /** Hash routing for Observe UI (R5). */
2
2
 
3
3
  export function parseHashRoute(hashValue) {
4
- const hash = (hashValue.replace(/^#/, "") || "/").split("?")[0] || "/";
4
+ let hash = (hashValue.replace(/^#/, "") || "/").split("?")[0] || "/";
5
+ if (!hash.startsWith("/")) hash = `/${hash}`;
6
+ if (hash === "/inspect") hash = "/";
7
+ else if (hash.startsWith("/inspect/")) hash = hash.slice("/inspect".length) || "/";
5
8
  const batchMatch = /^\/batch\/([^/]+)$/.exec(hash);
6
9
  if (batchMatch) {
7
10
  return { view: "batch", batchRunId: decodeURIComponent(batchMatch[1]) };
@@ -61,7 +64,28 @@ export function parseRoute() {
61
64
  return parseHashRouteWithFilters(location.hash);
62
65
  }
63
66
 
67
+ let navigateHandler = null;
68
+
69
+ export function setNavigateHandler(handler) {
70
+ navigateHandler = typeof handler === "function" ? handler : null;
71
+ }
72
+
73
+ export function inspectPathToCanonicalHref(path) {
74
+ const raw = String(path ?? "");
75
+ if (raw.startsWith("/#/inspect")) return raw;
76
+ if (raw.startsWith("#/inspect")) return `/${raw}`;
77
+ const stripped = raw.replace(/^#/, "");
78
+ const withSlash = stripped.startsWith("/") ? stripped : `/${stripped}`;
79
+ if (withSlash === "/inspect" || withSlash === "/") return "/#/inspect";
80
+ if (withSlash.startsWith("/inspect/")) return `/#${withSlash}`;
81
+ return `/#/inspect${withSlash}`;
82
+ }
83
+
64
84
  export function navigate(path) {
85
+ if (navigateHandler) {
86
+ navigateHandler(inspectPathToCanonicalHref(path));
87
+ return;
88
+ }
65
89
  location.hash = path.startsWith("#") ? path.slice(1) : path;
66
90
  }
67
91