@tea-agent/loop-agent 0.35.4-beta.0 → 0.36.0

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 (55) hide show
  1. package/AGENTS.md +4 -2
  2. package/CHANGELOG.md +55 -7
  3. package/dist/application/task-lifecycle/advance.js +1 -0
  4. package/dist/application/task-lifecycle/observe.js +15 -0
  5. package/dist/application/task-lifecycle/plan-transitions.js +15 -0
  6. package/dist/build-stamp.json +3 -3
  7. package/dist/commands/init-upgrade.js +351 -19
  8. package/dist/commands/init.js +14 -67
  9. package/dist/commands/run-dag-progress.js +14 -0
  10. package/dist/commands/task-advance.js +33 -3
  11. package/dist/executors/dag-pi-executor.js +3 -1
  12. package/dist/shared/operator/capabilities.js +125 -11
  13. package/dist/task/source-prepare/completeness.js +17 -0
  14. package/dist/task/source-prepare/parse-intent.js +15 -1
  15. package/dist/worker/console/chat/artifact-card.js +8 -1
  16. package/dist/worker/console/chat/chat-event-store.js +61 -0
  17. package/dist/worker/console/chat/human-gate-card.js +9 -1
  18. package/dist/worker/console/chat/operation-card.js +71 -2
  19. package/dist/worker/console/chat/pi-runtime.js +69 -30
  20. package/dist/worker/console/chat/routes.js +27 -4
  21. package/dist/worker/console/chat/semantic-activity.js +465 -0
  22. package/dist/worker/console/chat/turn-process.js +31 -12
  23. package/dist/worker/console/operation-run-facts.js +190 -0
  24. package/dist/worker/console/operation-runner.js +107 -6
  25. package/dist/worker/console/operation-wait.js +314 -0
  26. package/dist/worker/console/operator-actions.js +153 -2
  27. package/dist/worker/console/static/assets/index-2OeZODxk.js +57 -0
  28. package/dist/worker/console/static/assets/index-DVJlUL8X.css +1 -0
  29. package/dist/worker/console/static/index.html +2 -2
  30. package/dist/worker/console/static-src/operator-chat/activity-journey.js +125 -0
  31. package/dist/worker/console/static-src/operator-chat/activity-rail-presentation.js +73 -0
  32. package/dist/worker/console/static-src/operator-chat/activity-references.js +20 -0
  33. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +69 -31
  34. package/dist/worker/console/static-src/operator-chat/slash-palette-layout.js +24 -0
  35. package/dist/worker/console/static-src/operator-chat/slash-palette-nav.js +141 -0
  36. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +2 -1
  37. package/dist/worker/console/static-src/operator-chat/useActivityRailTransition.js +59 -0
  38. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +16 -5
  39. package/dist/worker/console/static-src/operator-chat/useComposer.js +30 -7
  40. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +7 -3
  41. package/dist/worker/observe/static/operator-chrome.js +1 -1
  42. package/dist/workflows/dag/failure-routing.js +4 -9
  43. package/dist/workflows/dag/init-hybrid.js +74 -2
  44. package/dist/workflows/dag/lifecycle.js +0 -4
  45. package/dist/workflows/dag/report.js +0 -6
  46. package/docs/README.md +3 -3
  47. package/docs/architecture/evolution.md +102 -67
  48. package/docs/templates/frontend-task-constraints.md +7 -13
  49. package/docs/templates/init-managed-agents.md +5 -2
  50. package/harness.json +2 -2
  51. package/package.json +1 -1
  52. package/skills/loop-agent/SKILL.md +1 -0
  53. package/skills/loop-agent/references/command-reference.md +2 -0
  54. package/dist/worker/console/static/assets/index-DRqZiQ7J.css +0 -1
  55. package/dist/worker/console/static/assets/index-DuVLjCIT.js +0 -57
@@ -25,25 +25,30 @@ export function createChatSseEventApplier(deps) {
25
25
  // global error; the snapshot replay frames that follow are still applied
26
26
  // as ordinary blocks (deduped by eventId, replay text skipped).
27
27
  const reason = typeof data.reason === "string" ? data.reason : "cursor_expired";
28
- onReconcile?.({
28
+ const reconcileInfo = {
29
29
  reason,
30
30
  ...(typeof data.minSeq === "number" ? { minSeq: data.minSeq } : {}),
31
31
  ...(typeof data.epoch === "string" ? { epoch: data.epoch } : {}),
32
32
  ...(typeof data.latestTurnId === "string"
33
33
  ? { latestTurnId: data.latestTurnId }
34
34
  : {}),
35
- ...(typeof data.latestEventId === "string"
36
- ? { latestEventId: data.latestEventId }
37
- : data.latestEventId === null
38
- ? { latestEventId: null }
39
- : {}),
40
- });
35
+ };
36
+ if (typeof data.latestEventId === "string" || data.latestEventId === null) {
37
+ reconcileInfo.latestEventId = data.latestEventId;
38
+ }
39
+ onReconcile?.(reconcileInfo);
41
40
  return;
42
41
  }
43
42
  const payload = data.data && typeof data.data === "object"
44
43
  ? data.data
45
44
  : data;
46
45
  const turnId = typeof data.turnId === "string" ? data.turnId : undefined;
46
+ const eventSeq = typeof data.seq === "number" && Number.isFinite(data.seq)
47
+ ? data.seq
48
+ : undefined;
49
+ const eventAt = typeof data.at === "string" && Number.isFinite(Date.parse(data.at))
50
+ ? Date.parse(data.at)
51
+ : Date.now();
47
52
  if (turnId &&
48
53
  (eventName === "agent_start" || eventName === "message_update") &&
49
54
  refs.settledTurnIdsRef.current.has(turnId)) {
@@ -108,14 +113,26 @@ export function createChatSseEventApplier(deps) {
108
113
  }
109
114
  if (eventName === "human-gate-card") {
110
115
  const card = humanGateCardFromEventPayload(payload);
111
- if (card)
112
- setHumanGateCards((current) => mergeHumanGateCardState(current, card));
116
+ if (card) {
117
+ setHumanGateCards((current) => mergeHumanGateCardState(current, {
118
+ ...card,
119
+ ...(eventSeq === undefined ? {} : { eventSeq }),
120
+ ...(turnId ? { turnId } : {}),
121
+ activityRef: `gate:${card.cardId}`,
122
+ }));
123
+ }
113
124
  return;
114
125
  }
115
126
  if (eventName === "artifact-ref") {
116
127
  const artifact = artifactFromEventPayload(payload);
117
- if (artifact)
118
- setArtifactCards((current) => mergeArtifactCardState(current, artifact));
128
+ if (artifact) {
129
+ setArtifactCards((current) => mergeArtifactCardState(current, {
130
+ ...artifact,
131
+ ...(eventSeq === undefined ? {} : { eventSeq }),
132
+ ...(turnId ? { turnId } : {}),
133
+ activityRef: `artifact:${artifact.artifactId}`,
134
+ }));
135
+ }
119
136
  return;
120
137
  }
121
138
  if (eventName === "agent_start") {
@@ -172,7 +189,18 @@ export function createChatSseEventApplier(deps) {
172
189
  if (eventName === "operation-ref" || eventName === "operation-status") {
173
190
  const operation = operationFromEventPayload(payload);
174
191
  if (operation) {
175
- setOperationCards((current) => mergeOperationCardState(current, operation));
192
+ const toolCallId = typeof payload.toolCallId === "string"
193
+ ? payload.toolCallId
194
+ : undefined;
195
+ setOperationCards((current) => mergeOperationCardState(current, {
196
+ ...operation,
197
+ ...(eventSeq === undefined
198
+ ? {}
199
+ : { eventSeq, updatedSeq: eventSeq }),
200
+ ...(turnId ? { turnId } : {}),
201
+ ...(toolCallId ? { toolCallId } : {}),
202
+ activityRef: `operation:${operation.operationId}`,
203
+ }));
176
204
  }
177
205
  return;
178
206
  }
@@ -229,13 +257,15 @@ export function createChatSseEventApplier(deps) {
229
257
  setMessages((m) => {
230
258
  const exists = m.some((msg) => msg.id === assistantIdForEvent);
231
259
  if (exists) {
232
- return m.map((msg) => msg.id === assistantIdForEvent
233
- ? {
260
+ return m.map((msg) => {
261
+ if (msg.id !== assistantIdForEvent)
262
+ return msg;
263
+ return {
234
264
  ...msg,
235
265
  text,
236
266
  ...(content ? { content } : {}),
237
- }
238
- : msg);
267
+ };
268
+ });
239
269
  }
240
270
  // Reconnect path: the assistant message may not exist yet
241
271
  // (e.g. only tool_call events arrived). Append a placeholder.
@@ -245,7 +275,7 @@ export function createChatSseEventApplier(deps) {
245
275
  id: assistantIdForEvent,
246
276
  role: "assistant",
247
277
  text,
248
- createdAt: Date.now(),
278
+ createdAt: eventAt,
249
279
  ...(content ? { content } : {}),
250
280
  },
251
281
  ];
@@ -259,18 +289,20 @@ export function createChatSseEventApplier(deps) {
259
289
  : requestId("t");
260
290
  const toolName = typeof payload.toolName === "string" ? payload.toolName : "tool";
261
291
  const argsPreview = typeof payload.argsPreview === "string" ? payload.argsPreview : "{}";
262
- const now = Date.now();
263
292
  setToolCalls((prev) => {
264
293
  if (prev.some((t) => t.id === id)) {
265
- return prev.map((t) => t.id === id
266
- ? {
294
+ return prev.map((t) => {
295
+ if (t.id !== id)
296
+ return t;
297
+ return {
267
298
  ...t,
268
299
  args: argsPreview,
269
300
  status: "running",
270
301
  turnId: turnId ?? t.turnId,
271
302
  assistantMessageId: resolvedAssistantId,
272
- }
273
- : t);
303
+ ...(eventSeq === undefined ? {} : { eventSeq }),
304
+ };
305
+ });
274
306
  }
275
307
  return [
276
308
  ...prev,
@@ -279,7 +311,8 @@ export function createChatSseEventApplier(deps) {
279
311
  toolName,
280
312
  args: argsPreview,
281
313
  status: "running",
282
- createdAt: now,
314
+ createdAt: eventAt,
315
+ ...(eventSeq === undefined ? {} : { eventSeq }),
283
316
  turnId,
284
317
  assistantMessageId: resolvedAssistantId,
285
318
  },
@@ -295,21 +328,23 @@ export function createChatSseEventApplier(deps) {
295
328
  const resultText = typeof payload.resultPreview === "string"
296
329
  ? payload.resultPreview
297
330
  : "{}";
298
- const now = Date.now();
299
331
  setToolCalls((prev) => {
300
332
  const exists = prev.find((t) => t.id === id);
301
333
  if (exists) {
302
- return prev.map((t) => t.id === id
303
- ? {
334
+ return prev.map((t) => {
335
+ if (t.id !== id)
336
+ return t;
337
+ return {
304
338
  ...t,
305
339
  result: resultText,
306
340
  resultText,
307
341
  status: isError ? "error" : "ok",
308
- finishedAt: now,
342
+ finishedAt: eventAt,
343
+ ...(eventSeq === undefined ? {} : { finishedSeq: eventSeq }),
309
344
  turnId: turnId ?? t.turnId,
310
345
  assistantMessageId: t.assistantMessageId ?? resolvedAssistantId,
311
- }
312
- : t);
346
+ };
347
+ });
313
348
  }
314
349
  return [
315
350
  ...prev,
@@ -319,8 +354,11 @@ export function createChatSseEventApplier(deps) {
319
354
  result: resultText,
320
355
  resultText,
321
356
  status: isError ? "error" : "ok",
322
- createdAt: now,
323
- finishedAt: now,
357
+ createdAt: eventAt,
358
+ finishedAt: eventAt,
359
+ ...(eventSeq === undefined
360
+ ? {}
361
+ : { eventSeq, finishedSeq: eventSeq }),
324
362
  turnId,
325
363
  assistantMessageId: resolvedAssistantId,
326
364
  },
@@ -0,0 +1,24 @@
1
+ export const SHORTCUT_PALETTE_AVAILABLE_HEIGHT_VAR = "--oc-shortcut-palette-available-height";
2
+ export const SHORTCUT_PALETTE_TOP_INSET = 8;
3
+ export const SHORTCUT_PALETTE_ANCHOR_GAP = 8;
4
+ /**
5
+ * Resolve the real vertical room above the composer anchor. The palette is
6
+ * clipped by `.oc-main`, while a shifted VisualViewport (for example when the
7
+ * browser UI or virtual keyboard changes) can impose a lower visible top edge.
8
+ */
9
+ export function resolveShortcutPaletteAvailableHeight(geometry) {
10
+ const viewportTop = geometry.viewportTop ?? 0;
11
+ const topInset = geometry.topInset ?? SHORTCUT_PALETTE_TOP_INSET;
12
+ const anchorGap = geometry.anchorGap ?? SHORTCUT_PALETTE_ANCHOR_GAP;
13
+ const values = [
14
+ geometry.boundaryTop,
15
+ geometry.anchorTop,
16
+ viewportTop,
17
+ topInset,
18
+ anchorGap,
19
+ ];
20
+ if (values.some((value) => !Number.isFinite(value)))
21
+ return null;
22
+ const visibleTop = Math.max(geometry.boundaryTop, viewportTop);
23
+ return Math.max(0, Math.floor(geometry.anchorTop - visibleTop - topInset - anchorGap));
24
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Slash palette display + navigation model (UI-12 card grid).
3
+ *
4
+ * Display order (SLASH_GROUP_DISPLAY_ORDER) is intentionally decoupled from
5
+ * the conflict/priority order in shortcuts.ts (SOURCE_PRIORITY): merge and
6
+ * dedupe keep SOURCE_PRIORITY semantics (skill 3 < prompt 4), while rendering
7
+ * follows the display order (prompt group before skill group). buildPaletteModel
8
+ * models that divergence explicitly — every card carries its flat-list
9
+ * globalIndex so keyboard/AT state round-trips from the rendered position back
10
+ * to filteredShortcuts[globalIndex]. shortcuts.ts itself stays untouched.
11
+ *
12
+ * All functions here are pure/DOM-free so the geometric and row-aware
13
+ * navigation can be unit-tested in node with fixture rects.
14
+ */
15
+ import { SLASH_SOURCE_LABEL, } from "../../chat/shortcuts.js";
16
+ /** Group render order (independent from SOURCE_PRIORITY conflict order). */
17
+ export const SLASH_GROUP_DISPLAY_ORDER = [
18
+ "operator",
19
+ "web",
20
+ "extension",
21
+ "prompt",
22
+ "skill",
23
+ ];
24
+ /** Group filtered candidates by display order, skipping empty groups. */
25
+ export function buildPaletteModel(entries) {
26
+ const groups = [];
27
+ for (const source of SLASH_GROUP_DISPLAY_ORDER) {
28
+ const items = [];
29
+ for (let i = 0; i < entries.length; i += 1) {
30
+ if (entries[i].source === source) {
31
+ items.push({ entry: entries[i], globalIndex: i });
32
+ }
33
+ }
34
+ if (items.length === 0)
35
+ continue;
36
+ groups.push({ source, label: SLASH_SOURCE_LABEL[source], items });
37
+ }
38
+ return groups;
39
+ }
40
+ /** Keep the active option index inside the current candidate range. */
41
+ export function clampShortcutIndex(index, length) {
42
+ if (!Number.isFinite(length) || length <= 0)
43
+ return 0;
44
+ if (!Number.isFinite(index) || index < 0)
45
+ return 0;
46
+ if (index >= length)
47
+ return length - 1;
48
+ return index;
49
+ }
50
+ /** CSS grid columns per workspace layout mode (single source: layoutMode). */
51
+ export function paletteColumnCount(mode) {
52
+ if (mode === "comfortable")
53
+ return 3;
54
+ if (mode === "compact")
55
+ return 2;
56
+ return 1;
57
+ }
58
+ /**
59
+ * Group- and row-aware movement on the displayed card grid.
60
+ *
61
+ * Left/Right move to the adjacent card in the current row and stay inside the
62
+ * current group (row start/end stick; group headers are never entered; narrow
63
+ * 1-column layout is a no-op). Up/Down move within the same column to the
64
+ * adjacent row, may cross group boundaries, and stick at grid edges — never
65
+ * wrapping around and never leaving [0, entries.length - 1].
66
+ */
67
+ export function movePaletteIndex(currentIndex, model, columns, direction) {
68
+ const groupPos = model.findIndex((group) => group.items.some((card) => card.globalIndex === currentIndex));
69
+ if (groupPos < 0)
70
+ return currentIndex;
71
+ const group = model[groupPos];
72
+ const offset = group.items.findIndex((card) => card.globalIndex === currentIndex);
73
+ if (offset < 0)
74
+ return currentIndex;
75
+ const row = Math.floor(offset / columns);
76
+ const col = offset % columns;
77
+ if (direction === "left" || direction === "right") {
78
+ if (columns <= 1)
79
+ return currentIndex;
80
+ const delta = direction === "left" ? -1 : 1;
81
+ const targetOffset = offset + delta;
82
+ if (targetOffset < 0 || targetOffset >= group.items.length) {
83
+ return currentIndex;
84
+ }
85
+ if (Math.floor(targetOffset / columns) !== row)
86
+ return currentIndex;
87
+ return group.items[targetOffset].globalIndex;
88
+ }
89
+ const delta = direction === "up" ? -1 : 1;
90
+ const targetOffset = offset + delta * columns;
91
+ if (targetOffset >= 0 && targetOffset < group.items.length) {
92
+ return group.items[targetOffset].globalIndex;
93
+ }
94
+ // Cross-group: walk to the previous/next non-empty group and target the
95
+ // same column on its last (up) / first (down) row; stick when that column
96
+ // does not exist in the partial edge row.
97
+ for (let cursor = groupPos + delta; cursor >= 0 && cursor < model.length; cursor += delta) {
98
+ const candidate = model[cursor];
99
+ if (candidate.items.length === 0)
100
+ continue;
101
+ const lastRowStart = Math.floor((candidate.items.length - 1) / columns) * columns;
102
+ const target = direction === "up" ? lastRowStart + col : col;
103
+ if (target >= candidate.items.length)
104
+ return currentIndex;
105
+ return candidate.items[target].globalIndex;
106
+ }
107
+ return currentIndex;
108
+ }
109
+ /**
110
+ * Geometry-first Up/Down targeting: from the active card's measured rect,
111
+ * pick the adjacent row's candidate whose horizontal center is nearest the
112
+ * active card's center (group boundaries may be crossed). Returns null when
113
+ * no candidate exists above/below (or the layout was not measured), so the
114
+ * caller falls back to movePaletteIndex. globalIndex comes from
115
+ * data-shortcut-index — never from the DOM list position.
116
+ */
117
+ export function pickAdjacentRowTarget(activeRect, cells, direction) {
118
+ const activeCenterX = (activeRect.left + activeRect.right) / 2;
119
+ let best = null;
120
+ for (const cell of cells) {
121
+ if (cell.globalIndex === activeRect.globalIndex)
122
+ continue;
123
+ const gap = direction === "up"
124
+ ? activeRect.top - cell.bottom
125
+ : cell.top - activeRect.bottom;
126
+ // Same-row cells overlap vertically (gap < 0) and must be ignored.
127
+ if (gap < 0)
128
+ continue;
129
+ const centerX = (cell.left + cell.right) / 2;
130
+ const dist = Math.abs(centerX - activeCenterX);
131
+ if (best === null ||
132
+ gap < best.gap ||
133
+ (gap === best.gap && dist < best.dist) ||
134
+ (gap === best.gap &&
135
+ dist === best.dist &&
136
+ cell.globalIndex < best.index)) {
137
+ best = { gap, dist, index: cell.globalIndex };
138
+ }
139
+ }
140
+ return best?.index ?? null;
141
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Mutual-exclusion state for large spatial overlays in Operator Chat.
3
- * At most one of: sidebar drawer, process, runtime context, tools.
3
+ * At most one of: sidebar drawer, process, runtime context, tools, activity.
4
4
  * Small composer menus (model/thinking) are out of scope.
5
5
  */
6
6
  export function createSpatialOverlayState(active = "none") {
@@ -33,5 +33,6 @@ export function spatialOverlayFlags(state) {
33
33
  processOpen: state.active === "process",
34
34
  runtimeContextOpen: state.active === "runtime-context",
35
35
  toolsOpen: state.active === "tools",
36
+ activityOpen: state.active === "activity",
36
37
  };
37
38
  }
@@ -0,0 +1,59 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ export const ACTIVITY_RAIL_ENTER_MS = 160;
3
+ export const ACTIVITY_RAIL_LEAVE_MS = 120;
4
+ export const ACTIVITY_RAIL_SWITCH_MS = 120;
5
+ export function activityRailSnapshotTotal(snapshot) {
6
+ if (!snapshot)
7
+ return 0;
8
+ return (snapshot.operationCards.length +
9
+ snapshot.humanGateCards.length +
10
+ snapshot.artifactCards.length);
11
+ }
12
+ /**
13
+ * Keep the Rail shell independent from ChatMain while sessions change. Old
14
+ * content is non-interactive during the short switching/leaving phase, then is
15
+ * replaced or removed without ever changing conversation geometry.
16
+ */
17
+ export function useActivityRailTransition(current) {
18
+ const currentTotal = activityRailSnapshotTotal(current);
19
+ const [state, setState] = useState(() => currentTotal > 0
20
+ ? { phase: "visible", snapshot: current }
21
+ : { phase: "hidden", snapshot: null });
22
+ const stateRef = useRef(state);
23
+ stateRef.current = state;
24
+ const currentKey = useMemo(() => `${current?.sessionId ?? "none"}:${currentTotal}`, [current?.sessionId, currentTotal]);
25
+ useEffect(() => {
26
+ const timers = [];
27
+ const displayed = stateRef.current.snapshot;
28
+ if (!current || currentTotal === 0) {
29
+ if (!displayed) {
30
+ setState({ phase: "hidden", snapshot: null });
31
+ return;
32
+ }
33
+ setState({ phase: "leaving", snapshot: displayed });
34
+ timers.push(window.setTimeout(() => {
35
+ setState({ phase: "hidden", snapshot: null });
36
+ }, ACTIVITY_RAIL_LEAVE_MS));
37
+ }
38
+ else if (!displayed) {
39
+ setState({ phase: "entering", snapshot: current });
40
+ timers.push(window.setTimeout(() => {
41
+ setState({ phase: "visible", snapshot: current });
42
+ }, ACTIVITY_RAIL_ENTER_MS));
43
+ }
44
+ else if (displayed.sessionId !== current.sessionId) {
45
+ setState({ phase: "switching", snapshot: current });
46
+ timers.push(window.setTimeout(() => {
47
+ setState({ phase: "visible", snapshot: current });
48
+ }, ACTIVITY_RAIL_SWITCH_MS));
49
+ }
50
+ else {
51
+ setState({ phase: "visible", snapshot: current });
52
+ }
53
+ return () => {
54
+ for (const timer of timers)
55
+ window.clearTimeout(timer);
56
+ };
57
+ }, [current, currentKey, currentTotal]);
58
+ return state;
59
+ }
@@ -146,8 +146,6 @@ export function useChatSessions(params) {
146
146
  }
147
147
  }, [origin, refs, resetActiveProjection, setSession]);
148
148
  const deleteSession = useCallback(async (sessionId) => {
149
- if (!window.confirm("确定删除该会话?删除后不可恢复。"))
150
- return;
151
149
  try {
152
150
  const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}`, {
153
151
  method: "DELETE",
@@ -158,7 +156,7 @@ export function useChatSessions(params) {
158
156
  });
159
157
  if (!res.ok) {
160
158
  setError("删除会话失败");
161
- return;
159
+ return false;
162
160
  }
163
161
  titleWatcherRef.current?.cancel(sessionId);
164
162
  if (refs.sessionRef.current?.sessionId === sessionId) {
@@ -169,9 +167,11 @@ export function useChatSessions(params) {
169
167
  window.localStorage.removeItem(ACTIVE_CHAT_SESSION_STORAGE_KEY);
170
168
  }
171
169
  await refreshSessionList();
170
+ return true;
172
171
  }
173
172
  catch (error) {
174
173
  setError(error instanceof Error ? error.message : String(error));
174
+ return false;
175
175
  }
176
176
  }, [
177
177
  origin,
@@ -372,15 +372,26 @@ export function useChatSessions(params) {
372
372
  createSessionAndPrimeTitleWatcherForHook,
373
373
  ]);
374
374
  // Phase-1: fork / in-session branch / mainline switch UI deferred; API routes remain.
375
- const compactSession = useCallback(async () => {
375
+ /** Single compact helper for toolbar + `/compact <instructions>` (AC-1). */
376
+ const compactSession = useCallback(async (instructions) => {
376
377
  const current = refs.sessionRef.current;
377
378
  if (!current || refs.streamingRef.current)
378
379
  return;
380
+ const trimmed = typeof instructions === "string" ? instructions.trim() : "";
379
381
  try {
382
+ const headers = {
383
+ "x-loop-console-confirmation": confirmationToken(),
384
+ };
385
+ if (trimmed) {
386
+ headers["content-type"] = "application/json";
387
+ }
380
388
  const response = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(current.sessionId)}/compact`, {
381
389
  method: "POST",
382
390
  credentials: "include",
383
- headers: { "x-loop-console-confirmation": confirmationToken() },
391
+ headers,
392
+ ...(trimmed
393
+ ? { body: JSON.stringify({ instructions: trimmed }) }
394
+ : {}),
384
395
  });
385
396
  const body = (await response.json().catch(() => ({})));
386
397
  if (!response.ok) {
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react";
2
2
  import { filterSlashCommands, mergeSlashCommands, } from "../../chat/shortcuts.js";
3
3
  import { confirmationToken } from "./format.js";
4
+ import { clampShortcutIndex } from "./slash-palette-nav.js";
4
5
  import { attachRuntimeSnapshotController } from "./useRuntimeSnapshot.js";
5
6
  /** Composer state: input text, @file palette, image attachments, /shortcuts,
6
7
  * IME composition guards, auto-grow and server-side draft sync. */
@@ -30,12 +31,22 @@ export function useComposer(params) {
30
31
  const [dynamicCommandsError, setDynamicCommandsError] = useState(null);
31
32
  const [commandsEpoch, setCommandsEpoch] = useState(0);
32
33
  const textareaRef = useRef(null);
33
- const commandsSessionRef = useRef(undefined);
34
+ /** Active session generation for /commands; bumped immediately on switch (AC-2). */
35
+ const commandsSessionRef = useRef(sessionId);
36
+ const commandsGenerationRef = useRef(0);
34
37
  const handleInputChange = useCallback((value) => {
35
38
  setInput(value);
36
39
  setShortcutOpen(value.startsWith("/"));
37
40
  setShortcutIndex(0);
38
41
  }, []);
42
+ // Immediate invalidation on A→B even when B palette never opens (AC-2).
43
+ useLayoutEffect(() => {
44
+ commandsSessionRef.current = sessionId;
45
+ commandsGenerationRef.current += 1;
46
+ setDynamicCommands([]);
47
+ setDynamicCommandsError(null);
48
+ setDynamicCommandsLoading(false);
49
+ }, [sessionId]);
39
50
  const loadDynamicCommands = useCallback(async () => {
40
51
  if (!sessionId) {
41
52
  setDynamicCommands([]);
@@ -44,12 +55,15 @@ export function useComposer(params) {
44
55
  return;
45
56
  }
46
57
  const target = sessionId;
58
+ const generation = commandsGenerationRef.current;
47
59
  commandsSessionRef.current = target;
48
60
  setDynamicCommandsLoading(true);
49
61
  setDynamicCommandsError(null);
62
+ const isCurrent = () => commandsSessionRef.current === target &&
63
+ commandsGenerationRef.current === generation;
50
64
  try {
51
65
  const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(target)}/commands`, { credentials: "include" });
52
- if (commandsSessionRef.current !== target)
66
+ if (!isCurrent())
53
67
  return;
54
68
  if (!res.ok) {
55
69
  setDynamicCommandsError("动态命令加载失败");
@@ -75,26 +89,27 @@ export function useComposer(params) {
75
89
  source,
76
90
  });
77
91
  }
78
- if (commandsSessionRef.current !== target)
92
+ if (!isCurrent())
79
93
  return;
80
94
  setDynamicCommands(items);
95
+ // Compact server fail-soft / partial-source warning; local Operator/Web still merge.
96
+ const warning = typeof body?.warning === "string" ? body.warning.trim() : "";
97
+ setDynamicCommandsError(warning ? warning.slice(0, 160) : null);
81
98
  }
82
99
  catch {
83
- if (commandsSessionRef.current !== target)
100
+ if (!isCurrent())
84
101
  return;
85
102
  setDynamicCommandsError("动态命令加载失败");
86
103
  setDynamicCommands([]);
87
104
  }
88
105
  finally {
89
- if (commandsSessionRef.current === target) {
106
+ if (isCurrent()) {
90
107
  setDynamicCommandsLoading(false);
91
108
  }
92
109
  }
93
110
  }, [origin, sessionId]);
94
111
  // Lazy-load dynamic commands when slash palette opens or session changes.
95
112
  useEffect(() => {
96
- setDynamicCommands([]);
97
- setDynamicCommandsError(null);
98
113
  if (!sessionId)
99
114
  return;
100
115
  if (!shortcutOpen && commandsEpoch === 0)
@@ -186,6 +201,14 @@ export function useComposer(params) {
186
201
  const filteredShortcuts = useMemo(() => input.startsWith("/")
187
202
  ? filterSlashCommands(mergedCommands, input)
188
203
  : [], [input, mergedCommands]);
204
+ // Keep the active option index inside the current candidate range (UI-12
205
+ // AC-011): dynamic loads completing/failing empty, retries, session
206
+ // switches, late responses and search shrink all funnel through
207
+ // filteredShortcuts.length. The clamped index always matches a rendered
208
+ // `oc-shortcut-option-${globalIndex}` id (same source of truth).
209
+ useEffect(() => {
210
+ setShortcutIndex((index) => clampShortcutIndex(index, filteredShortcuts.length));
211
+ }, [filteredShortcuts.length]);
189
212
  const onCompositionStart = useCallback(() => {
190
213
  refs.isComposingRef.current = true;
191
214
  }, [refs]);
@@ -17,9 +17,13 @@ export function resolveWorkspaceLayoutMode(widthPx) {
17
17
  return "compact";
18
18
  return "narrow";
19
19
  }
20
- /** Whether process should render as a right overlay (no main reflow). */
21
- export function processUsesOverlay(mode) {
22
- return mode === "compact" || mode === "narrow";
20
+ /**
21
+ * Tool-call history always uses the original right overlay panel.
22
+ * Reflowing it as a comfortable-mode third column moves ChatMain and also
23
+ * invalidates Activity Rail's fixed ChatMain anchor.
24
+ */
25
+ export function processUsesOverlay(_mode) {
26
+ return true;
23
27
  }
24
28
  /** Whether the permanent full sidebar is allowed. */
25
29
  export function fullSidebarAllowed(mode) {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Operator Chrome — 统一壳(Operate React 与 Inspect vanilla 共用唯一实现)。
3
3
  *
4
- * 设计真源:docs/design/active/2026-08-09-unified-operator-chrome.md
4
+ * 设计真源:docs/design/archive/2026-08-09-unified-operator-chrome.md
5
5
  * - 一条 chrome 两条带:Band1 全局(品牌/仓库身份/模式分段控件/对象上下文/状态格),
6
6
  * Band2 工作区(下划线 tab,随模式切换内容)。
7
7
  * - 模式语义:操作=做,观测=看同一对象;模式切换在持有 DAG 选中时不丢对象。
@@ -44,9 +44,7 @@ function routeToProductLine(input) {
44
44
  case "decision-envelope":
45
45
  return "NeedsHuman";
46
46
  case "skipped":
47
- return input.skippedReason === "frontend-prewrite-not-authorized"
48
- ? "ContractMismatch"
49
- : "DependencyFailure";
47
+ return "DependencyFailure";
50
48
  case "shell-command":
51
49
  if (/\b(ENOENT|PATH|command not found|No such file|not found in PATH|bash: .*: No such file)\b/i.test(raw)) {
52
50
  return "EnvFailure";
@@ -92,12 +90,9 @@ export function routeDagFailure(input) {
92
90
  if (!productLineFailureCategory)
93
91
  return {};
94
92
  const recommendedFollowUp = productLineFailureCategory === "ContractMismatch" &&
95
- input.skippedReason === "frontend-prewrite-not-authorized"
96
- ? "fix-frontend-task-contract-and-regenerate-dag"
97
- : productLineFailureCategory === "ContractMismatch" &&
98
- isFrontendDesignGateNode(input.nodeId ?? "")
99
- ? "frontend-plan-revision-and-rerun"
100
- : FOLLOW_UP_BY_PRODUCT_LINE[productLineFailureCategory];
93
+ isFrontendDesignGateNode(input.nodeId ?? "")
94
+ ? "frontend-plan-revision-and-rerun"
95
+ : FOLLOW_UP_BY_PRODUCT_LINE[productLineFailureCategory];
101
96
  return {
102
97
  productLineFailureCategory,
103
98
  recommendedFollowUp,