@tea-agent/loop-agent 0.40.0-next.12 → 0.40.0-next.13

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 (114) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +1 -1
  3. package/dist/build-stamp.json +3 -3
  4. package/dist/cli/command-definitions.js +9 -5
  5. package/dist/cli/program.js +38 -3
  6. package/dist/commands/task-advance.js +23 -1
  7. package/dist/commands/task-source-prepare.js +5 -0
  8. package/dist/commands/task-status.js +1 -0
  9. package/dist/shared/operator/capabilities.js +9 -1
  10. package/dist/worker/console/chat/assistant-content.js +7 -0
  11. package/dist/worker/console/chat/chat-event-store.js +82 -11
  12. package/dist/worker/console/chat/goal-round-driver.js +72 -0
  13. package/dist/worker/console/chat/goals.js +300 -0
  14. package/dist/worker/console/chat/pi-runtime.js +140 -4
  15. package/dist/worker/console/chat/repo-browser.js +94 -12
  16. package/dist/worker/console/chat/routes.js +482 -29
  17. package/dist/worker/console/chat/session-catalog.js +48 -0
  18. package/dist/worker/console/chat/session-store.js +27 -18
  19. package/dist/worker/console/chat/shortcuts.js +7 -0
  20. package/dist/worker/console/chat/turn-process.js +32 -3
  21. package/dist/worker/console/operator-actions.js +16 -2
  22. package/dist/worker/console/operator-selection.js +6 -2
  23. package/dist/worker/console/pi-plugins.catalog.json +73658 -0
  24. package/dist/worker/console/pi-plugins.js +100 -0
  25. package/dist/worker/console/routes.js +221 -6
  26. package/dist/worker/console/server.js +172 -42
  27. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-C_hkwt2J.js → abnfDiagram-N423BO3Z-Wj-fsQlk.js} +1 -1
  28. package/dist/worker/console/static/assets/{arc-s2Q6r96B.js → arc-CkXT-vzQ.js} +1 -1
  29. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CtgsrwU1.js → architectureDiagram-T3A2C74G-xlfnojWU.js} +1 -1
  30. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-DZ-A7EiJ.js → blockDiagram-VBNYF7ZC-BilahvuB.js} +1 -1
  31. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-D2qv0Nyo.js → c4Diagram-5PPSVZJV-DqtAVhjz.js} +1 -1
  32. package/dist/worker/console/static/assets/channel-SnQ_u-Td.js +1 -0
  33. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-D-UYpSaW.js → chunk-2GRJ4B5K-pAqQXZ5y.js} +1 -1
  34. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-DAS3jrrt.js → chunk-2Q5K7J3B-BEWxeq2r.js} +1 -1
  35. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-CSbuRcGB.js → chunk-5RXB4S5H-CvlbGDTp.js} +1 -1
  36. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BDH1ZO40.js → chunk-5VM5RSS4-D0uGd9-f.js} +1 -1
  37. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-Dc2cqQIb.js → chunk-6Q2QTUOP-DSdWIteg.js} +1 -1
  38. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CkZuemIW.js → chunk-GF5L2VYU-Dweg7f0v.js} +1 -1
  39. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-URwjQnte.js → chunk-JWPE2WC7-BpZKI_k7.js} +1 -1
  40. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-04RHylJK.js → chunk-KBJHAD2P-IPLknC4m.js} +1 -1
  41. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-CvQX56Hh.js → chunk-RYQCIY6F-E93k9lVI.js} +1 -1
  42. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-dUF7qura.js → chunk-XXDRQBXY-BrESwkPq.js} +1 -1
  43. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-SGM51NVY.js +1 -0
  44. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-SGM51NVY.js +1 -0
  45. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-COwbfxJE.js → cose-bilkent-JH36ORCC-DmFHvJkB.js} +1 -1
  46. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-CaBEJekQ.js → cynefin-VYW2F7L2-CDigXiSL.js} +1 -1
  47. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-COWUBdet.js → cynefinDiagram-MW4NZA55-B0zhrbaA.js} +1 -1
  48. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BLxVqTn4.js → dagre-VZM6K2ZE-DrKybVxm.js} +1 -1
  49. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-WL0yKfEQ.js → diagram-7IWD3JNH-CeUrnbFk.js} +1 -1
  50. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-DaVkKCle.js → diagram-B4RE2ZJO-BJyunUMi.js} +1 -1
  51. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DdTgnRGf.js → diagram-LBJQPF4R-a5TBP73u.js} +1 -1
  52. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-PG2Axo6A.js → diagram-Q27KOJAE-ChRgBURJ.js} +1 -1
  53. package/dist/worker/console/static/assets/{diagram-UB23O5K3-6vuQzlzI.js → diagram-UB23O5K3-CqCnPRSk.js} +1 -1
  54. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BpXeBz-g.js → ebnfDiagram-BXEA7PRR-BI-UD9_S.js} +1 -1
  55. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-PxtUnaa1.js → erDiagram-JOGREHBK-CDLvvkYZ.js} +1 -1
  56. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-B--Qgvs1.js → flowDiagram-UKHOOZJN-Heoo4afj.js} +1 -1
  57. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-BIfQCgtx.js → ganttDiagram-PKOTCBZU-kFpOitHd.js} +1 -1
  58. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-BRaX_E4j.js → gitGraphDiagram-DS77QQ5N-FvyZ9WAY.js} +1 -1
  59. package/dist/worker/console/static/assets/index-C80SO8y0.js +437 -0
  60. package/dist/worker/console/static/assets/index-CmJCHmJP.css +1 -0
  61. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-Cd93r4Ab.js → infoDiagram-6WML65LV-HBzlwtgI.js} +1 -1
  62. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-C4c80iHt.js → ishikawaDiagram-WSZJBQD7-C9Iim5sm.js} +1 -1
  63. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-Ch5ajf3s.js → journeyDiagram-NVQOT4AX-CJwJTg0S.js} +1 -1
  64. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-DbpyW1Ut.js → kanban-definition-27J2QSJJ-CZNJ7S4q.js} +1 -1
  65. package/dist/worker/console/static/assets/{linear-BHVjBnTW.js → linear-RZ0AnGi8.js} +1 -1
  66. package/dist/worker/console/static/assets/{mermaid.core-DO70kJ34.js → mermaid.core-EYSdm-nd.js} +5 -5
  67. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-_P44Zpvs.js → mindmap-definition-FAOFIHXS-D0OLECFN.js} +1 -1
  68. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-Bgi9FmEd.js → pegDiagram-VL7TDLO6-CPTvGbh7.js} +1 -1
  69. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-C9InDfJn.js → pieDiagram-7S7Q4E2Y-Ddc1aDea.js} +1 -1
  70. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-DtKrUouv.js → quadrantDiagram-CIZ2JOQS-CWCTCdAt.js} +1 -1
  71. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-zgAD_6Yo.js → railroadDiagram-AXF67PYL-CfsaBL8E.js} +1 -1
  72. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CQNO6kj4.js → requirementDiagram-LRYGKXZP-BH2rqGf6.js} +1 -1
  73. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-knKu111P.js → sankeyDiagram-W5VNT64P-BZgJ1Til.js} +1 -1
  74. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-D6tshiQ6.js → sequenceDiagram-SI44F4Z6-DmPr-o-y.js} +1 -1
  75. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-BMIWJcir.js → sizeCapture-X5ZJPWSS-CF6HzDh_.js} +1 -1
  76. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-BP9wRDqc.js → stateDiagram-OKZ733FA-DhS8N_B9.js} +1 -1
  77. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-N56zxwsk.js +1 -0
  78. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DEkM5Dhe.js → swimlanes-SLNWSIFB-CYNzoiaD.js} +2 -2
  79. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-o47kGgVt.js +8 -0
  80. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-ByBKsL35.js → timeline-definition-Z64GVDOM-QBICF5qH.js} +1 -1
  81. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-waFbNCUy.js → vennDiagram-T6HMQDX7-fquBvWcf.js} +1 -1
  82. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-1ljiY0eZ.js → wardleyDiagram-T6FBY63Y-BYaJLlSd.js} +1 -1
  83. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-B4ZLHr_L.js → xychartDiagram-ELKLHX3M--ePe_JCJ.js} +1 -1
  84. package/dist/worker/console/static/index.html +2 -2
  85. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +95 -25
  86. package/dist/worker/console/static-src/operator-chat/compaction-message.js +35 -1
  87. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +3 -1
  88. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +24 -19
  89. package/dist/worker/console/static-src/operator-chat/useChatStream.js +0 -11
  90. package/dist/worker/console/static-src/operator-chat/useChatThread.js +8 -1
  91. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +34 -4
  92. package/dist/worker/console/static-src/operator-chat/workspace-reorder.js +18 -0
  93. package/dist/worker/console/static-src/shell/useWorkspaces.js +150 -34
  94. package/dist/worker/console/workspace-context.js +28 -3
  95. package/dist/worker/console/workspace-registry.js +333 -60
  96. package/dist/worker/observe/static/inspect-bootstrap.d.ts +8 -0
  97. package/dist/worker/observe/static/inspect-bootstrap.js +57 -0
  98. package/dist/worker/observe/static/inspect-workspace.js +54 -10
  99. package/dist/worker/observe/static/operator-chrome.css +23 -4
  100. package/dist/worker/observe/static/operator-chrome.js +6 -2
  101. package/dist/worker/observe/static/styles.css +78 -25
  102. package/dist/worker/observe/static/views/dag-inspector.js +2 -1
  103. package/dist/worker/observe/static/views/dags.js +7 -5
  104. package/dist/workflows/dag/init-hybrid.js +3 -3
  105. package/docs/templates/backend-test-dag.json +3 -3
  106. package/package.json +1 -1
  107. package/skills/loop-agent/references/command-reference.md +14 -0
  108. package/dist/worker/console/static/assets/channel-BluaMT3-.js +0 -1
  109. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-H-ZpSafr.js +0 -1
  110. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-H-ZpSafr.js +0 -1
  111. package/dist/worker/console/static/assets/index-Crrgl8Mx.css +0 -1
  112. package/dist/worker/console/static/assets/index-vPsp-idE.js +0 -437
  113. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-ClEhytQG.js +0 -1
  114. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DPg5IMuu.js +0 -8
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { runtimeSelectionFromRecord } from "../../chat/runtime-selection.js";
3
3
  import { hydrateSessionThread } from "../../chat/turn-process.js";
4
4
  import { forgetActiveChatSession, readActiveChatSessionId, rememberActiveChatSession, } from "../../chat/workspace-landing.js";
5
- import { CHAT_SESSION_LIST_LIMIT } from "../chat-view-types.js";
6
5
  import { confirmationToken } from "./format.js";
7
6
  import { upsertCompactionMessage } from "./compaction-message.js";
8
7
  import { describeMutationGateFailure, isMutationGateFailureCode, isStaleTokenFailure, } from "./mutation-gate.js";
@@ -49,7 +48,7 @@ export async function createSessionAndPrimeTitleWatcher(input) {
49
48
  const nextList = [
50
49
  durable,
51
50
  ...input.sessionListRef.current.filter((item) => item.sessionId !== durable.sessionId),
52
- ].slice(0, CHAT_SESSION_LIST_LIMIT);
51
+ ];
53
52
  // Commit ref → state → eligibility → deferred refresh. The ref assignment
54
53
  // makes the durable record observable before React's state commit.
55
54
  input.sessionListRef.current = nextList;
@@ -78,8 +77,8 @@ export function useChatSessions(params) {
78
77
  const workspaceQuery = workspaceId
79
78
  ? `?workspaceId=${encodeURIComponent(workspaceId)}`
80
79
  : "";
81
- /** AC-002 (2026-08-26): session requests follow the session's DURABLE
82
- * owning workspace, not the global active workspace. The sidebar list
80
+ /** AC-002 (2026-08-26): session detail and mutation requests follow the
81
+ * session's DURABLE owning workspace, not the global active workspace. The sidebar list
83
82
  * items carry workspaceId; once a session is activated its owner is kept
84
83
  * on the session record itself. Switching the Operate/Inspect active
85
84
  * workspace therefore never retargets an open session's requests. */
@@ -147,26 +146,30 @@ export function useChatSessions(params) {
147
146
  if (blocked)
148
147
  return; // AC-R5-002: fail-closed, zero session requests
149
148
  try {
150
- const res = await fetch(`${origin}/api/operator/v1/chat/sessions${workspaceQuery}`, {
149
+ // The sidebar is a multi-workspace index. Keep one unscoped summary
150
+ // collection so expanding or switching a workspace never projects the
151
+ // previous workspace's list as an empty result while a scoped refresh is
152
+ // in flight. Session detail and mutation requests remain owner-scoped.
153
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions`, {
151
154
  credentials: "include",
152
155
  });
153
- if (!res.ok)
156
+ const body = (await res.json().catch(() => ({})));
157
+ if (!res.ok) {
158
+ setError(body.error?.message ?? `会话列表加载失败(HTTP ${res.status})`);
154
159
  return;
155
- const sessions = (await res.json())
156
- .sessions ?? [];
157
- // Multi-workspace (AC-002): list only this workspace's sessions when a
158
- // scope is active; unscoped (legacy) keeps the full list.
159
- const scoped = workspaceId
160
- ? sessions.filter((item) => !item.workspaceId || item.workspaceId === workspaceId)
161
- : sessions;
162
- // API returns newest-first; keep only the recent window in the sidebar.
163
- const keep = scoped.slice(0, CHAT_SESSION_LIST_LIMIT);
164
- setSessionList(keep);
160
+ }
161
+ const sessions = body.sessions ?? [];
162
+ // Preserve every workspace's summaries. ChatWorkspaceSidebar applies the
163
+ // visible recent-item limit independently inside each workspace group.
164
+ setSessionList(sessions);
165
+ if (body.workspaceErrors?.length) {
166
+ setError(`部分工作区的会话加载失败(${body.workspaceErrors.length} 个)`);
167
+ }
165
168
  }
166
- catch {
167
- /* Chat remains usable without the sidebar index. */
169
+ catch (error) {
170
+ setError(`会话列表加载失败:${error instanceof Error ? error.message : String(error)}`);
168
171
  }
169
- }, [origin, workspaceQuery, workspaceId, blocked]);
172
+ }, [origin, blocked, setError]);
170
173
  const deleteSession = useCallback(async (sessionId) => {
171
174
  try {
172
175
  // AC-002: delete follows the session's durable owner, not the
@@ -268,6 +271,7 @@ export function useChatSessions(params) {
268
271
  deferredTurns: body.session.deferredTurns ?? (body.session.deferredTurn ? [body.session.deferredTurn] : []),
269
272
  pendingQuestion: body.session.pendingQuestion ?? null,
270
273
  todos: body.session.todos ?? [],
274
+ goal: body.session.goal ?? null,
271
275
  });
272
276
  rememberActiveChatSession(window.localStorage, body.session.workspaceId ?? ownerWorkspaceId ?? workspaceId, sessionId);
273
277
  setActivePreviewPath(null);
@@ -398,6 +402,7 @@ export function useChatSessions(params) {
398
402
  deferredTurns: body.session.deferredTurns ?? (body.session.deferredTurn ? [body.session.deferredTurn] : []),
399
403
  pendingQuestion: body.session.pendingQuestion ?? null,
400
404
  todos: body.session.todos ?? [],
405
+ goal: body.session.goal ?? null,
401
406
  });
402
407
  }
403
408
  catch {
@@ -404,14 +404,12 @@ export function useChatStream(params) {
404
404
  const activeSession = refs.sessionRef.current;
405
405
  if (!activeSession || (!input.text.trim() && input.images.length === 0))
406
406
  return { action: "handled" };
407
- showNotice("正在排队…");
408
407
  const acceptDeferred = (deferredTurn, deferredTurns) => {
409
408
  setDeferredTurn(deferredTurns?.[0] ?? deferredTurn);
410
409
  setDeferredTurns(deferredTurns ?? [deferredTurn]);
411
410
  setInput("");
412
411
  setPendingImages([]);
413
412
  setError(null);
414
- showNotice("已排队,将在当前回合结束后自动发送");
415
413
  };
416
414
  const acceptDispatched = () => {
417
415
  setDeferredTurn(null);
@@ -419,7 +417,6 @@ export function useChatStream(params) {
419
417
  setInput("");
420
418
  setPendingImages([]);
421
419
  setError(null);
422
- showNotice("消息已进入发送");
423
420
  };
424
421
  const putOnce = async () => {
425
422
  const response = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(activeSession.sessionId)}/deferred-turn`, {
@@ -472,7 +469,6 @@ export function useChatStream(params) {
472
469
  if (decision.action === "retry-deferred" && attempt === 0)
473
470
  continue;
474
471
  if (decision.action === "send-now") {
475
- showNotice("上一轮已停止,正在发送当前消息…");
476
472
  return { action: "send-now" };
477
473
  }
478
474
  setError(body.error?.message ?? "停止状态已变化,当前消息仍保留在输入框");
@@ -496,7 +492,6 @@ export function useChatStream(params) {
496
492
  return { action: "handled" };
497
493
  }
498
494
  if (decision.action === "send-now") {
499
- showNotice("上一轮已停止,正在发送当前消息…");
500
495
  return { action: "send-now" };
501
496
  }
502
497
  setError(error instanceof Error
@@ -513,7 +508,6 @@ export function useChatStream(params) {
513
508
  setInput,
514
509
  setPendingImages,
515
510
  setError,
516
- showNotice,
517
511
  fetchSessionState,
518
512
  ]);
519
513
  /** DELETE the queued turn; "restore" moves its text back into the composer,
@@ -549,10 +543,6 @@ export function useChatStream(params) {
549
543
  if (mode === "restore") {
550
544
  setInput(deferred.text ?? body.deferredTurn?.text ?? "");
551
545
  setPendingImages(deferred.images ?? body.deferredTurn?.images ?? []);
552
- showNotice("待发送消息已移回输入框");
553
- }
554
- else {
555
- showNotice("已删除排队消息");
556
546
  }
557
547
  setError(null);
558
548
  }
@@ -569,7 +559,6 @@ export function useChatStream(params) {
569
559
  setInput,
570
560
  setPendingImages,
571
561
  setError,
572
- showNotice,
573
562
  ]);
574
563
  const cancelDeferredTurn = useCallback(() => requestDeferredTurnDelete("restore"), [requestDeferredTurnDelete]);
575
564
  const discardDeferredTurn = useCallback((target) => requestDeferredTurnDelete("discard", target), [requestDeferredTurnDelete]);
@@ -44,7 +44,7 @@ function operationCardFromOperationFact(op) {
44
44
  state,
45
45
  createdAt,
46
46
  updatedAt,
47
- inspectHref: `/inspect/?operationId=${encodeURIComponent(operationId)}#/`,
47
+ inspectHref: `/#/inspect?operationId=${encodeURIComponent(operationId)}`,
48
48
  ...(str("taskId") ? { taskId: str("taskId") } : {}),
49
49
  ...(dagRunId ? { dagRunId } : {}),
50
50
  ...(str("errorCode") ? { errorCode: str("errorCode") } : {}),
@@ -131,6 +131,7 @@ export function useChatThread(params) {
131
131
  const [deferredTurns, setDeferredTurns] = useState([]);
132
132
  const [pendingQuestion, setPendingQuestion] = useState(null);
133
133
  const [todos, setTodos] = useState([]);
134
+ const [goal, setGoal] = useState(null);
134
135
  /** Assistant bubble id for the in-flight turn; only this row shows the streaming cursor. */
135
136
  const [streamingAssistantId, setStreamingAssistantId] = useState(null);
136
137
  const [showSystemInMessages] = useState(false);
@@ -169,6 +170,7 @@ export function useChatThread(params) {
169
170
  setDeferredTurns,
170
171
  setPendingQuestion,
171
172
  setTodos,
173
+ setGoal,
172
174
  setStreamingAssistantId,
173
175
  setUnread,
174
176
  setOperationCards,
@@ -337,6 +339,7 @@ export function useChatThread(params) {
337
339
  setDeferredTurns([]);
338
340
  setPendingQuestion(null);
339
341
  setTodos([]);
342
+ setGoal(null);
340
343
  setStreamingAssistantId(null);
341
344
  setUnread(0);
342
345
  setHistoryLimit(50);
@@ -363,6 +366,7 @@ export function useChatThread(params) {
363
366
  setDeferredTurns(next.deferredTurns ?? (next.deferredTurn ? [next.deferredTurn] : []));
364
367
  setPendingQuestion(next.pendingQuestion ?? null);
365
368
  setTodos(next.todos ?? []);
369
+ setGoal(next.goal ?? null);
366
370
  setStreamingAssistantId(null);
367
371
  setUnread(0);
368
372
  setHistoryLimit(50);
@@ -393,6 +397,7 @@ export function useChatThread(params) {
393
397
  setDeferredTurns(body.session.deferredTurns ?? (body.session.deferredTurn ? [body.session.deferredTurn] : []));
394
398
  setPendingQuestion(body.session.pendingQuestion ?? null);
395
399
  setTodos(body.session.todos ?? []);
400
+ setGoal(body.session.goal ?? null);
396
401
  if (body.sessionStats)
397
402
  setSessionStats(body.sessionStats);
398
403
  if (body.usage)
@@ -455,6 +460,8 @@ export function useChatThread(params) {
455
460
  pendingQuestion,
456
461
  setPendingQuestion,
457
462
  todos,
463
+ goal,
464
+ setGoal,
458
465
  streamingAssistantId,
459
466
  setStreamingAssistantId,
460
467
  visibleMessages,
@@ -1,5 +1,31 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { ACTIVE_CHAT_SESSION_STORAGE_KEY } from "../../chat/workspace-landing.js";
3
+ const REPO_HTML_PREVIEW_ROUTE_PREFIX = "/api/operator/v1/chat/repo/html/";
4
+ /** DSH-style path-encoded preview URL: relative HTML assets resolve under the
5
+ * same workspace-scoped route instead of losing a query-string workspaceId. */
6
+ export function repoHtmlPreviewUrl(origin, workspaceId, file) {
7
+ const id = workspaceId?.trim();
8
+ const segments = file
9
+ .replaceAll("\\", "/")
10
+ .split("/")
11
+ .filter(Boolean);
12
+ if (!id || segments.length === 0)
13
+ return undefined;
14
+ const base = origin.endsWith("/") ? origin.slice(0, -1) : origin;
15
+ return `${base}${REPO_HTML_PREVIEW_ROUTE_PREFIX}${encodeURIComponent(id)}/${segments.map(encodeURIComponent).join("/")}`;
16
+ }
17
+ function withHtmlPreviewUrl(preview, origin, workspaceId) {
18
+ const language = preview.language.trim().toLowerCase();
19
+ if (language !== "html" && language !== "htm")
20
+ return preview;
21
+ const htmlPreviewUrl = repoHtmlPreviewUrl(origin, workspaceId, preview.path);
22
+ if (!htmlPreviewUrl)
23
+ return preview;
24
+ return {
25
+ ...preview,
26
+ htmlPreviewUrl,
27
+ };
28
+ }
3
29
  /** Details-rail repo tree + file preview tabs state. */
4
30
  export function useRepoBrowser(params) {
5
31
  const { origin, refs, sessionId, workspaceId, blocked, setError } = params;
@@ -77,17 +103,18 @@ export function useRepoBrowser(params) {
77
103
  return;
78
104
  if (!res.ok || !body.data)
79
105
  throw new Error(body.error?.message ?? "无法预览文件");
106
+ const loaded = withHtmlPreviewUrl(body.data, origin, workspaceId);
80
107
  setPreviews((current) => {
81
108
  const next = [
82
- ...current.filter((item) => item.path !== body.data.path),
83
- body.data,
109
+ ...current.filter((item) => item.path !== loaded.path),
110
+ loaded,
84
111
  ];
85
112
  const activeSessionId = refs.sessionRef.current?.sessionId;
86
113
  if (activeSessionId)
87
114
  window.localStorage.setItem(`${ACTIVE_CHAT_SESSION_STORAGE_KEY}.previews.${activeSessionId}`, JSON.stringify(next.map((item) => item.path)));
88
115
  return next;
89
116
  });
90
- setActivePreviewPath(body.data.path);
117
+ setActivePreviewPath(loaded.path);
91
118
  }
92
119
  catch (error) {
93
120
  if (workspaceScopeRef.current !== requestWorkspaceId)
@@ -152,7 +179,10 @@ export function useRepoBrowser(params) {
152
179
  const res = await fetch(`${origin}/api/operator/v1/chat/repo/file?path=${encodeURIComponent(file)}${workspaceQuery}`, { credentials: "include" });
153
180
  if (!res.ok)
154
181
  return undefined;
155
- return (await res.json()).data;
182
+ const restored = (await res.json()).data;
183
+ return restored
184
+ ? withHtmlPreviewUrl(restored, origin, workspaceId)
185
+ : undefined;
156
186
  })).then((restored) => {
157
187
  if (refs.sessionRef.current?.sessionId !== sessionId)
158
188
  return;
@@ -0,0 +1,18 @@
1
+ /** Resolve DSH-style half-row hover to an insert-before anchor; undefined is a no-op. */
2
+ export function resolveWorkspaceDropAnchor(workspaces, sourceId, targetWorkspaceId, half) {
3
+ const targetIndex = workspaces.findIndex((workspace) => workspace.workspaceId === targetWorkspaceId);
4
+ const sourceIndex = workspaces.findIndex((workspace) => workspace.workspaceId === sourceId);
5
+ if (targetIndex === -1 || sourceIndex === -1)
6
+ return undefined;
7
+ const beforeWorkspaceId = half === "before"
8
+ ? targetWorkspaceId
9
+ : (workspaces[targetIndex + 1]?.workspaceId ?? null);
10
+ if (beforeWorkspaceId === sourceId)
11
+ return undefined;
12
+ const anchorIndex = beforeWorkspaceId
13
+ ? workspaces.findIndex((workspace) => workspace.workspaceId === beforeWorkspaceId)
14
+ : workspaces.length;
15
+ return anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1
16
+ ? undefined
17
+ : beforeWorkspaceId;
18
+ }
@@ -4,7 +4,7 @@ import { describeMutationGateFailure, isMutationGateFailureCode, } from "../oper
4
4
  /**
5
5
  * Multi-workspace registry state (2026-08-23, AC-001/AC-003).
6
6
  *
7
- * Consumes the process-level GET/POST/DELETE /api/operator/v1/workspaces
7
+ * Consumes the process-level GET/POST/PATCH/DELETE /api/operator/v1/workspaces
8
8
  * surface. POST/DELETE pass the Console mutation-gate confirmation token.
9
9
  * The selected workspaceId is remembered in localStorage and validated
10
10
  * against the visible list. A stale local preference falls back to the first
@@ -64,6 +64,11 @@ export function createWorkspacesController(params = {}) {
64
64
  const storage = params.storage;
65
65
  const token = params.confirmationTokenFn ?? confirmationToken;
66
66
  const listeners = new Set();
67
+ let appliedRevision = 0;
68
+ let refreshQueued = false;
69
+ let refreshPromise = null;
70
+ let eventStream;
71
+ let disposed = false;
67
72
  let state = {
68
73
  workspaces: [],
69
74
  loading: true,
@@ -79,44 +84,90 @@ export function createWorkspacesController(params = {}) {
79
84
  state = { ...state, ...patch };
80
85
  emit();
81
86
  };
82
- const refresh = async () => {
87
+ const refresh = () => {
88
+ refreshQueued = true;
89
+ if (refreshPromise)
90
+ return refreshPromise;
91
+ refreshPromise = (async () => {
92
+ while (refreshQueued && !disposed) {
93
+ refreshQueued = false;
94
+ try {
95
+ const res = await fetchImpl(`${origin}/api/operator/v1/workspaces`, {
96
+ credentials: "include",
97
+ });
98
+ if (!res.ok)
99
+ continue;
100
+ const body = (await res.json());
101
+ const revision = Number.isSafeInteger(body.revision)
102
+ ? Math.max(0, body.revision ?? 0)
103
+ : undefined;
104
+ // Legacy/injected responses without revision remain usable. A
105
+ // revisioned response can never roll the projection backwards.
106
+ if (revision !== undefined && revision < appliedRevision)
107
+ continue;
108
+ if (revision !== undefined)
109
+ appliedRevision = revision;
110
+ const list = body.workspaces ?? [];
111
+ const pick = pickDefaultWorkspaceId(list, state.workspaceId);
112
+ if (pick.status === "unknown-workspace") {
113
+ setState({
114
+ workspaces: list,
115
+ unknownWorkspaceId: pick.workspaceId,
116
+ });
117
+ continue;
118
+ }
119
+ const nextId = pick.workspaceId;
120
+ const previousId = state.workspaceId;
121
+ setState({
122
+ workspaces: list,
123
+ unknownWorkspaceId: null,
124
+ ...(nextId !== previousId ? { workspaceId: nextId } : {}),
125
+ });
126
+ if (nextId !== previousId)
127
+ rememberWorkspaceId(nextId, storage);
128
+ }
129
+ catch {
130
+ /* Registry unreachable: keep last known list. */
131
+ }
132
+ finally {
133
+ if (state.loading)
134
+ setState({ loading: false });
135
+ }
136
+ }
137
+ })().finally(() => {
138
+ refreshPromise = null;
139
+ // An event can arrive between the loop condition and finally.
140
+ if (refreshQueued && !disposed)
141
+ void refresh();
142
+ });
143
+ return refreshPromise;
144
+ };
145
+ const consumeRegistryEvent = (event) => {
83
146
  try {
84
- const res = await fetchImpl(`${origin}/api/operator/v1/workspaces`, {
85
- credentials: "include",
86
- });
87
- if (!res.ok)
88
- return;
89
- const body = (await res.json());
90
- const list = body.workspaces ?? [];
91
- // A stale local preference falls back to the first server-visible
92
- // Workspace. Explicit route ids are validated separately and remain
93
- // fail-closed.
94
- const pick = pickDefaultWorkspaceId(list, state.workspaceId);
95
- if (pick.status === "unknown-workspace") {
96
- setState({
97
- workspaces: list,
98
- unknownWorkspaceId: pick.workspaceId,
99
- });
100
- return;
147
+ const revision = Number(JSON.parse(event.data).revision);
148
+ if (Number.isSafeInteger(revision) && revision > appliedRevision) {
149
+ void refresh();
101
150
  }
102
- const nextId = pick.workspaceId;
103
- const previousId = state.workspaceId;
104
- setState({
105
- workspaces: list,
106
- unknownWorkspaceId: null,
107
- ...(nextId !== previousId ? { workspaceId: nextId } : {}),
108
- });
109
- if (nextId !== previousId)
110
- rememberWorkspaceId(nextId, storage);
111
151
  }
112
152
  catch {
113
- /* Registry unreachable: keep last known list. */
114
- }
115
- finally {
116
- if (state.loading)
117
- setState({ loading: false });
153
+ /* Malformed frames are ignored; reconnect baseline remains authoritative. */
118
154
  }
119
155
  };
156
+ const startEventStream = () => {
157
+ if (eventStream || disposed)
158
+ return;
159
+ const factory = params.eventSourceFactory ??
160
+ (typeof EventSource !== "undefined"
161
+ ? (url) => new EventSource(url, {
162
+ withCredentials: true,
163
+ })
164
+ : undefined);
165
+ if (!factory)
166
+ return;
167
+ eventStream = factory(`${origin}/api/operator/v1/workspaces/events`);
168
+ eventStream.addEventListener("workspace_registry_ready", consumeRegistryEvent);
169
+ eventStream.addEventListener("workspace_registry_changed", consumeRegistryEvent);
170
+ };
120
171
  const addWorkspace = async (directoryPath, name) => {
121
172
  setState({ error: null });
122
173
  try {
@@ -214,12 +265,66 @@ export function createWorkspacesController(params = {}) {
214
265
  return { ok: false, error: display };
215
266
  }
216
267
  };
268
+ const reorderWorkspace = async (workspaceId, beforeWorkspaceId) => {
269
+ const previous = state.workspaces;
270
+ const source = previous.find((workspace) => workspace.workspaceId === workspaceId);
271
+ const anchor = beforeWorkspaceId
272
+ ? previous.find((workspace) => workspace.workspaceId === beforeWorkspaceId)
273
+ : undefined;
274
+ if (!source || (beforeWorkspaceId && !anchor)) {
275
+ return { ok: false, error: "工作区顺序已变化,请稍后重试。" };
276
+ }
277
+ const optimistic = previous.filter((workspace) => workspace.workspaceId !== workspaceId);
278
+ const insertAt = beforeWorkspaceId
279
+ ? optimistic.findIndex((workspace) => workspace.workspaceId === beforeWorkspaceId)
280
+ : optimistic.length;
281
+ optimistic.splice(insertAt, 0, source);
282
+ if (optimistic.every((workspace, index) => workspace.workspaceId === previous[index]?.workspaceId)) {
283
+ return { ok: true };
284
+ }
285
+ setState({ workspaces: optimistic, error: null });
286
+ try {
287
+ const res = await fetchImpl(`${origin}/api/operator/v1/workspaces/${encodeURIComponent(workspaceId)}`, {
288
+ method: "PATCH",
289
+ credentials: "include",
290
+ headers: {
291
+ "Content-Type": "application/json",
292
+ "x-loop-console-confirmation": token(),
293
+ },
294
+ body: JSON.stringify({ beforeWorkspaceId }),
295
+ });
296
+ const body = (await res.json().catch(() => ({})));
297
+ if (!res.ok) {
298
+ const message = isMutationGateFailureCode(body.error?.code)
299
+ ? describeMutationGateFailure(body.error?.code ?? "")
300
+ : (body.error?.message ?? `调整 Workspace 顺序失败(HTTP ${res.status})`);
301
+ setState({ workspaces: previous, error: message });
302
+ void refresh();
303
+ return { ok: false, error: message };
304
+ }
305
+ return { ok: true };
306
+ }
307
+ catch (error) {
308
+ const message = `调整 Workspace 顺序失败:${error instanceof Error ? error.message : String(error)}`;
309
+ setState({ workspaces: previous, error: message });
310
+ void refresh();
311
+ return { ok: false, error: message };
312
+ }
313
+ };
217
314
  const selectWorkspace = (next) => {
218
315
  if (next === state.workspaceId && !state.unknownWorkspaceId)
219
316
  return;
220
317
  setState({ workspaceId: next, unknownWorkspaceId: null });
221
318
  rememberWorkspaceId(next, storage);
222
319
  };
320
+ const dispose = () => {
321
+ if (disposed)
322
+ return;
323
+ disposed = true;
324
+ eventStream?.close();
325
+ eventStream = undefined;
326
+ listeners.clear();
327
+ };
223
328
  void refresh();
224
329
  return {
225
330
  get workspaces() {
@@ -241,10 +346,19 @@ export function createWorkspacesController(params = {}) {
241
346
  pickWorkspaceDirectory,
242
347
  addWorkspace,
243
348
  removeWorkspace,
349
+ reorderWorkspace,
244
350
  selectWorkspace,
351
+ dispose,
245
352
  subscribe(listener) {
246
353
  listeners.add(listener);
247
- return () => listeners.delete(listener);
354
+ startEventStream();
355
+ return () => {
356
+ listeners.delete(listener);
357
+ if (listeners.size === 0) {
358
+ eventStream?.close();
359
+ eventStream = undefined;
360
+ }
361
+ };
248
362
  },
249
363
  };
250
364
  }
@@ -294,6 +408,8 @@ export function useWorkspaces(params) {
294
408
  pickWorkspaceDirectory: controller.pickWorkspaceDirectory,
295
409
  addWorkspace: controller.addWorkspace,
296
410
  removeWorkspace: controller.removeWorkspace,
411
+ reorderWorkspace: controller.reorderWorkspace,
297
412
  selectWorkspace: controller.selectWorkspace,
413
+ dispose: controller.dispose,
298
414
  }), [state, controller]);
299
415
  }
@@ -33,6 +33,9 @@ import { createChatTurnExecutionRegistry } from "./chat/turn-execution-registry.
33
33
  import { ConsolePiRuntime } from "./chat/pi-runtime.js";
34
34
  import { UserQuestionRegistry } from "./chat/user-questions.js";
35
35
  import { TodoListRegistry } from "./chat/todos.js";
36
+ import { GoalRegistry } from "./chat/goals.js";
37
+ import { GoalRoundDriver } from "./chat/goal-round-driver.js";
38
+ import { driveGoalRound, recoverDeferredTurns } from "./chat/routes.js";
36
39
  import { createOperationEventStore, } from "./operation-sse.js";
37
40
  import { OperationStore } from "./operation-store.js";
38
41
  import { probePiReadiness, } from "./pi-readiness.js";
@@ -107,7 +110,16 @@ export async function createWorkspaceContext(record, deps) {
107
110
  fetchImpl: deps.fetchImpl,
108
111
  getConsoleOrigin: deps.getConsoleOrigin,
109
112
  };
110
- const chatStore = new ChatSessionStore(appData);
113
+ const chatStore = new ChatSessionStore(appData, {
114
+ onRecordChange: (sessionId, session) => {
115
+ if (session) {
116
+ deps.chatSessionCatalog?.upsert(session, record.workspaceId);
117
+ }
118
+ else {
119
+ deps.chatSessionCatalog?.remove(sessionId);
120
+ }
121
+ },
122
+ });
111
123
  const chatEventEpoch = randomBytes(12).toString("hex");
112
124
  const chatEvents = createChatEventStore({
113
125
  appData,
@@ -116,6 +128,7 @@ export async function createWorkspaceContext(record, deps) {
116
128
  const chatTurnExecutions = createChatTurnExecutionRegistry();
117
129
  const questionRegistry = new UserQuestionRegistry(path.join(appData.chats, "user-questions"));
118
130
  const todoRegistry = new TodoListRegistry();
131
+ const goalRegistry = new GoalRegistry(chatEvents);
119
132
  questionRegistry.subscribe((change) => {
120
133
  const question = change.question;
121
134
  chatEvents.append(question.sessionId, question.turnId ?? `${question.sessionId}:question`, {
@@ -144,21 +157,27 @@ export async function createWorkspaceContext(record, deps) {
144
157
  actionContext,
145
158
  questionRegistry,
146
159
  todoRegistry,
160
+ goalRegistry,
147
161
  });
148
- const chatRouteDeps = {
162
+ let chatRouteDeps;
163
+ const goalRoundDriver = new GoalRoundDriver((sessionId) => driveGoalRound(chatRouteDeps, sessionId));
164
+ chatRouteDeps = {
149
165
  runtime: chatRuntime,
150
166
  store: chatStore,
151
167
  events: chatEvents,
152
168
  turnExecutions: chatTurnExecutions,
153
169
  questionRegistry,
154
170
  todoRegistry,
171
+ goalRegistry,
172
+ goalRoundDriver,
155
173
  operationLinker: chatOperationLinker,
156
174
  appData,
157
175
  bootToken: deps.bootToken,
158
176
  getConsoleOrigin: deps.getConsoleOrigin,
159
177
  allowNonLoopbackAccess: deps.allowNonLoopbackAccess,
178
+ attachSession: (sessionId) => registry.attachSession(record.workspaceId, sessionId),
179
+ detachSession: (sessionId) => registry.detachSession(record.workspaceId, sessionId),
160
180
  };
161
- const { recoverDeferredTurns } = await import("./chat/routes.js");
162
181
  const nightAuxTicker = new NightAuxTicker({ repoRoot, client });
163
182
  const observeRouteContext = createObserveRouteContext(repoRoot, {
164
183
  staticDir: deps.inspectStaticDir,
@@ -191,6 +210,12 @@ export async function createWorkspaceContext(record, deps) {
191
210
  if (disposed)
192
211
  return;
193
212
  disposed = true;
213
+ try {
214
+ goalRoundDriver.dispose();
215
+ }
216
+ catch {
217
+ // best-effort
218
+ }
194
219
  try {
195
220
  chatOperationLinker.dispose();
196
221
  }