@tea-agent/loop-agent 0.35.3 → 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.
- package/AGENTS.md +4 -2
- package/CHANGELOG.md +59 -0
- package/dist/application/task-lifecycle/advance.js +1 -0
- package/dist/application/task-lifecycle/observe.js +15 -0
- package/dist/application/task-lifecycle/plan-transitions.js +15 -0
- package/dist/build-stamp.json +3 -3
- package/dist/commands/init-upgrade.js +351 -19
- package/dist/commands/init.js +14 -67
- package/dist/commands/run-dag-progress.js +14 -0
- package/dist/commands/task-advance.js +33 -3
- package/dist/executors/dag-pi-executor.js +3 -1
- package/dist/shared/operator/capabilities.js +125 -11
- package/dist/task/source-prepare/completeness.js +17 -0
- package/dist/task/source-prepare/parse-intent.js +15 -1
- package/dist/worker/console/chat/artifact-card.js +8 -1
- package/dist/worker/console/chat/chat-event-store.js +61 -0
- package/dist/worker/console/chat/human-gate-card.js +9 -1
- package/dist/worker/console/chat/operation-card.js +71 -2
- package/dist/worker/console/chat/pi-runtime.js +69 -30
- package/dist/worker/console/chat/routes.js +27 -4
- package/dist/worker/console/chat/semantic-activity.js +465 -0
- package/dist/worker/console/chat/turn-process.js +31 -12
- package/dist/worker/console/operation-run-facts.js +190 -0
- package/dist/worker/console/operation-runner.js +107 -6
- package/dist/worker/console/operation-wait.js +314 -0
- package/dist/worker/console/operator-actions.js +153 -2
- package/dist/worker/console/static/assets/index-2OeZODxk.js +57 -0
- package/dist/worker/console/static/assets/index-DVJlUL8X.css +1 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/operator-chat/activity-journey.js +125 -0
- package/dist/worker/console/static-src/operator-chat/activity-rail-presentation.js +73 -0
- package/dist/worker/console/static-src/operator-chat/activity-references.js +20 -0
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +69 -31
- package/dist/worker/console/static-src/operator-chat/slash-palette-layout.js +24 -0
- package/dist/worker/console/static-src/operator-chat/slash-palette-nav.js +141 -0
- package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +2 -1
- package/dist/worker/console/static-src/operator-chat/useActivityRailTransition.js +59 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +16 -5
- package/dist/worker/console/static-src/operator-chat/useComposer.js +30 -7
- package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +7 -3
- package/dist/worker/observe/static/operator-chrome.js +1 -1
- package/dist/workflows/dag/init-hybrid.js +74 -2
- package/docs/README.md +3 -3
- package/docs/architecture/evolution.md +102 -67
- package/docs/templates/init-managed-agents.md +5 -2
- package/harness.json +2 -2
- package/package.json +1 -1
- package/skills/loop-agent/SKILL.md +1 -0
- package/skills/loop-agent/references/command-reference.md +2 -0
- package/dist/worker/console/static/assets/index-DRqZiQ7J.css +0 -1
- 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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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:
|
|
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) =>
|
|
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
|
-
|
|
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:
|
|
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) =>
|
|
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:
|
|
342
|
+
finishedAt: eventAt,
|
|
343
|
+
...(eventSeq === undefined ? {} : { finishedSeq: eventSeq }),
|
|
309
344
|
turnId: turnId ?? t.turnId,
|
|
310
345
|
assistantMessageId: t.assistantMessageId ?? resolvedAssistantId,
|
|
311
|
-
}
|
|
312
|
-
|
|
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:
|
|
323
|
-
finishedAt:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
100
|
+
if (!isCurrent())
|
|
84
101
|
return;
|
|
85
102
|
setDynamicCommandsError("动态命令加载失败");
|
|
86
103
|
setDynamicCommands([]);
|
|
87
104
|
}
|
|
88
105
|
finally {
|
|
89
|
-
if (
|
|
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
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
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/
|
|
4
|
+
* 设计真源:docs/design/archive/2026-08-09-unified-operator-chrome.md
|
|
5
5
|
* - 一条 chrome 两条带:Band1 全局(品牌/仓库身份/模式分段控件/对象上下文/状态格),
|
|
6
6
|
* Band2 工作区(下划线 tab,随模式切换内容)。
|
|
7
7
|
* - 模式语义:操作=做,观测=看同一对象;模式切换在持有 DAG 选中时不丢对象。
|