@tea-agent/loop-agent 0.33.7-beta.0 → 0.34.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 (83) hide show
  1. package/CHANGELOG.md +38 -17
  2. package/dist/application/task-lifecycle/advance.js +254 -4
  3. package/dist/application/task-lifecycle/gates.js +50 -0
  4. package/dist/application/task-lifecycle/observe.js +11 -2
  5. package/dist/commands/init-upgrade.js +32 -1
  6. package/dist/commands/init.js +94 -3
  7. package/dist/executors/shell-executor.js +4 -91
  8. package/dist/executors/shell-write-guard.js +26 -8
  9. package/dist/shared/operator/capabilities.js +72 -42
  10. package/dist/shared/resilient-git.js +133 -0
  11. package/dist/task/source-prepare/artifact-meta.js +137 -0
  12. package/dist/task/source-prepare/index.js +2 -0
  13. package/dist/task/source-prepare/parse-intent.js +58 -10
  14. package/dist/task/source-prepare/prepare.js +180 -16
  15. package/dist/task/source-prepare/reference-integrity.js +18 -2
  16. package/dist/task/source-prepare/semantic-intake.js +404 -0
  17. package/dist/worker/console/app-data.js +2 -0
  18. package/dist/worker/console/chat/chat-event-store.js +190 -25
  19. package/dist/worker/console/chat/model-resolver.js +17 -0
  20. package/dist/worker/console/chat/pi-console-config.js +250 -32
  21. package/dist/worker/console/chat/pi-runtime.js +1007 -188
  22. package/dist/worker/console/chat/resource-loader.js +5 -4
  23. package/dist/worker/console/chat/routes.js +495 -157
  24. package/dist/worker/console/chat/runtime-context.js +48 -12
  25. package/dist/worker/console/chat/runtime-selection.js +59 -0
  26. package/dist/worker/console/chat/session-store.js +39 -0
  27. package/dist/worker/console/chat/shortcuts.js +1 -0
  28. package/dist/worker/console/chat/tool-adapter.js +9 -3
  29. package/dist/worker/console/chat/tools.js +5 -1
  30. package/dist/worker/console/dag-execution-receipt.js +380 -0
  31. package/dist/worker/console/operator-actions.js +559 -68
  32. package/dist/worker/console/server.js +8 -15
  33. package/dist/worker/console/static/assets/index-BQkhJpV8.css +1 -0
  34. package/dist/worker/console/static/assets/index-CMHovlqG.js +32 -0
  35. package/dist/worker/console/static/index.html +2 -2
  36. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +45 -8
  37. package/dist/worker/console/static-src/operator-chat/landing-density.js +23 -0
  38. package/dist/worker/console/static-src/operator-chat/refs.js +9 -0
  39. package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +257 -0
  40. package/dist/worker/console/static-src/operator-chat/session-title-watcher.js +128 -0
  41. package/dist/worker/console/static-src/operator-chat/sidebar-split.js +90 -0
  42. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +37 -0
  43. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +125 -22
  44. package/dist/worker/console/static-src/operator-chat/useChatStream.js +215 -184
  45. package/dist/worker/console/static-src/operator-chat/useChatThread.js +49 -5
  46. package/dist/worker/console/static-src/operator-chat/useComposer.js +17 -0
  47. package/dist/worker/console/static-src/operator-chat/useOverlayFocus.js +84 -0
  48. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +225 -74
  49. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +196 -0
  50. package/dist/worker/console/static-src/operator-chat/useWorkspaceLayout.js +58 -0
  51. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +31 -0
  52. package/dist/worker/delivery/final-verification.js +13 -5
  53. package/dist/worker/delivery/package.js +31 -19
  54. package/dist/worker/delivery/verification-bundle.js +6 -4
  55. package/dist/worker/observe/static/operator-chrome.css +5 -2
  56. package/dist/worker/observe/static/operator-chrome.js +6 -1
  57. package/dist/worker/observe/static/styles.css +39 -9
  58. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +33 -462
  59. package/dist/workflows/dag/backend-test-case-manifest.js +0 -4
  60. package/dist/workflows/dag/backend-test-markdown-workflow.js +1 -25
  61. package/dist/workflows/dag/backend-test-module-stem.js +0 -5
  62. package/dist/workflows/dag/backend-test-pytest-collection.js +24 -345
  63. package/dist/workflows/dag/backend-test-scenario-param.js +82 -269
  64. package/dist/workflows/dag/backend-test-writer-completeness.js +16 -47
  65. package/dist/workflows/dag/dynamic-runtime/map.js +8 -24
  66. package/dist/workflows/dag/frontend-worktree-diff.js +12 -27
  67. package/dist/workflows/dag/init-hybrid.js +34 -46
  68. package/dist/workflows/dag/types.js +0 -7
  69. package/dist/workflows/dag/workspace-checkpoint.js +8 -27
  70. package/docs/templates/backend-test-dag.json +29 -32
  71. package/harness.json +1 -1
  72. package/package.json +1 -1
  73. package/skills/local-jacoco-coverage/SKILL.md +281 -0
  74. package/skills/local-jacoco-coverage/references/requirement-to-source-mapping.md +85 -0
  75. package/skills/local-jacoco-coverage/references/runtime-alignment.md +106 -0
  76. package/skills/local-jacoco-coverage/scripts/run-coverage-analysis.sh +148 -0
  77. package/skills/local-jacoco-coverage/scripts/start-jacoco-agent.sh +110 -0
  78. package/skills/loop-agent/references/command-reference.md +3 -1
  79. package/skills/loop-agent/references/source-and-plan-practice.md +13 -0
  80. package/skills/loop-agent/references/task-workflow.md +4 -0
  81. package/dist/worker/console/chat/instruction-skills.js +0 -217
  82. package/dist/worker/console/static/assets/index-CnUXAqxG.css +0 -1
  83. package/dist/worker/console/static/assets/index-CteJFFL2.js +0 -29
@@ -6,8 +6,8 @@
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <link rel="stylesheet" href="/inspect/operator-chrome.css" />
8
8
  <title>Loop 操作台 · Operator Console</title>
9
- <script type="module" crossorigin src="/assets/index-CteJFFL2.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-CnUXAqxG.css">
9
+ <script type="module" crossorigin src="/assets/index-CMHovlqG.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-BQkhJpV8.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
@@ -9,20 +9,47 @@ import { requestId } from "./format.js";
9
9
  * original request would have. `assistantId` is the in-flight assistant
10
10
  * message id to patch for streaming text; reconnect creates one if none. */
11
11
  export function createChatSseEventApplier(deps) {
12
- const { refs, setError, setProcessOpen, setInterview, setHumanGateCards, setArtifactCards, setStreaming, setStreamingAssistantId, setUnread, setOperationCards, setUsage, setMessages, setToolCalls, } = deps;
12
+ const { refs, onReconcile, setError, setProcessOpen, setInterview, setHumanGateCards, setArtifactCards, setStreaming, setStreamingAssistantId, setUnread, setOperationCards, setUsage, setMessages, setToolCalls, } = deps;
13
13
  return (eventName, data, assistantId, options) => {
14
+ if (options?.generation !== undefined &&
15
+ refs.sessionGenerationRef.current !== options.generation)
16
+ return;
14
17
  const replay = options?.replay === true;
18
+ if (eventName === "stream_ready")
19
+ return;
15
20
  if (eventName === "reconcile") {
16
- // Cursor aged out of the server ring: history may be incomplete.
17
- // Surface a non-fatal notice rather than silently rendering a gap.
21
+ // Cursor aged out of the server ring (or the turn changed under us):
22
+ // retained history may be incomplete. Surface recovery via the callback
23
+ // (reload canonical facts -> new cursor -> reconnect) instead of a fatal
24
+ // global error; the snapshot replay frames that follow are still applied
25
+ // as ordinary blocks (deduped by eventId, replay text skipped).
18
26
  const reason = typeof data.reason === "string" ? data.reason : "cursor_expired";
19
- setError(`会话事件窗口已过期(${reason}),建议刷新历史或新建会话。`);
27
+ onReconcile?.({
28
+ reason,
29
+ ...(typeof data.minSeq === "number" ? { minSeq: data.minSeq } : {}),
30
+ ...(typeof data.epoch === "string" ? { epoch: data.epoch } : {}),
31
+ ...(typeof data.latestTurnId === "string"
32
+ ? { latestTurnId: data.latestTurnId }
33
+ : {}),
34
+ ...(typeof data.latestEventId === "string"
35
+ ? { latestEventId: data.latestEventId }
36
+ : data.latestEventId === null
37
+ ? { latestEventId: null }
38
+ : {}),
39
+ });
20
40
  return;
21
41
  }
22
42
  const payload = data.data && typeof data.data === "object"
23
43
  ? data.data
24
44
  : data;
25
45
  const turnId = typeof data.turnId === "string" ? data.turnId : undefined;
46
+ if (turnId &&
47
+ (eventName === "agent_start" || eventName === "message_update") &&
48
+ refs.settledTurnIdsRef.current.has(turnId)) {
49
+ // A delayed start/text frame from a completed turn must not create a
50
+ // ghost bubble or set streaming again (M5.4).
51
+ return;
52
+ }
26
53
  let resolvedAssistantId = assistantId;
27
54
  if (turnId) {
28
55
  const mapped = refs.turnAssistantIdsRef.current.get(turnId);
@@ -102,16 +129,26 @@ export function createChatSseEventApplier(deps) {
102
129
  turnId !== refs.activeTurnIdRef.current) {
103
130
  return;
104
131
  }
105
- if (turnId)
132
+ if (turnId) {
133
+ refs.settledTurnIdsRef.current.delete(turnId);
106
134
  refs.activeTurnIdRef.current = turnId;
135
+ }
136
+ refs.streamingRef.current = true;
107
137
  setStreaming(true);
108
138
  setStreamingAssistantId(resolvedAssistantId);
109
139
  return;
110
140
  }
111
141
  if (eventName === "agent_settled" || eventName === "agent_end") {
112
- // Both are terminal: agent_end = SDK run finished, agent_settled =
113
- // post-turn continuation idle. Clear streaming and drop an empty
114
- // placeholder bubble so a settled turn never shows a stuck "…" row.
142
+ if (turnId)
143
+ refs.settledTurnIdsRef.current.add(turnId);
144
+ // `agent_end` means the SDK run ended, but the Console keeps the only
145
+ // GET stream through an idle grace for agent_settled/state reconciliation
146
+ // (M4.4). It must not prematurely close the visible turn.
147
+ if (eventName === "agent_end")
148
+ return;
149
+ if (turnId === refs.activeTurnIdRef.current)
150
+ refs.activeTurnIdRef.current = null;
151
+ refs.streamingRef.current = false;
115
152
  setStreaming(false);
116
153
  setStreamingAssistantId(null);
117
154
  if (eventName === "agent_settled" &&
@@ -0,0 +1,23 @@
1
+ /** Landing density resolved from measured empty-container height. */
2
+ /** Height thresholds in CSS px against `.oc-messages.is-empty`. */
3
+ export const LANDING_DENSITY_THRESHOLDS = {
4
+ comfortableMin: 620,
5
+ compactMin: 480,
6
+ };
7
+ /**
8
+ * Resolve Landing visual density from empty-container block size.
9
+ * comfortable >=620, compact 480-619, extra-compact <480.
10
+ */
11
+ export function resolveLandingDensity(heightPx) {
12
+ if (!Number.isFinite(heightPx) || heightPx < 0)
13
+ return "extra-compact";
14
+ if (heightPx >= LANDING_DENSITY_THRESHOLDS.comfortableMin)
15
+ return "comfortable";
16
+ if (heightPx >= LANDING_DENSITY_THRESHOLDS.compactMin)
17
+ return "compact";
18
+ return "extra-compact";
19
+ }
20
+ /** CSS class suffix for density attribute binding. */
21
+ export function landingDensityClass(density) {
22
+ return `is-landing-${density}`;
23
+ }
@@ -7,6 +7,9 @@ export function useChatRefs() {
7
7
  const inlineStreamSessionIdsRef = useRef(new Set());
8
8
  const assistantIdRef = useRef(null);
9
9
  const lastEventIdRef = useRef(null);
10
+ const sessionGenerationRef = useRef(0);
11
+ const eventsStreamStateRef = useRef(null);
12
+ const settledTurnIdsRef = useRef(new Set());
10
13
  const seenEventIdsRef = useRef(new Set());
11
14
  const turnAssistantIdsRef = useRef(new Map());
12
15
  const reconnectAbortRef = useRef(null);
@@ -24,6 +27,9 @@ export function useChatRefs() {
24
27
  inlineStreamSessionIdsRef,
25
28
  assistantIdRef,
26
29
  lastEventIdRef,
30
+ sessionGenerationRef,
31
+ eventsStreamStateRef,
32
+ settledTurnIdsRef,
27
33
  seenEventIdsRef,
28
34
  turnAssistantIdsRef,
29
35
  reconnectAbortRef,
@@ -39,6 +45,9 @@ export function useChatRefs() {
39
45
  inlineStreamSessionIdsRef,
40
46
  assistantIdRef,
41
47
  lastEventIdRef,
48
+ sessionGenerationRef,
49
+ eventsStreamStateRef,
50
+ settledTurnIdsRef,
42
51
  seenEventIdsRef,
43
52
  turnAssistantIdsRef,
44
53
  reconnectAbortRef,
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Operator Chat — runtime snapshot store (design §2.2 / AC-04/05/06).
3
+ *
4
+ * A pure, framework-free state machine for the unified Live Runtime Snapshot.
5
+ * The browser treats the current Pi session as the single source of truth:
6
+ *
7
+ * status: idle → loading → ready | error
8
+ * reload: reloading (single-flight; no silent queueing) → success | failure
9
+ *
10
+ * `createRuntimeSnapshotStore` is intentionally side-effect free so it can be
11
+ * unit-tested in a plain Node environment; the React binding (fetch + attach)
12
+ * lives in `useRuntimeSnapshot.ts`.
13
+ */
14
+ export const runtimeSnapshotInitialState = {
15
+ status: "idle",
16
+ snapshot: null,
17
+ error: null,
18
+ reloading: false,
19
+ reloadError: null,
20
+ mutationBusy: false,
21
+ mutationToken: null,
22
+ };
23
+ /** Parse a failed HTTP body into a structured snapshot error (never empty). */
24
+ export function parseRuntimeSnapshotError(body) {
25
+ if (!body || typeof body !== "object") {
26
+ return { message: "runtime snapshot request failed" };
27
+ }
28
+ const record = body;
29
+ const err = record.error;
30
+ if (err && typeof err === "object") {
31
+ const e = err;
32
+ return {
33
+ ...(typeof e.code === "string" && e.code ? { code: e.code } : {}),
34
+ ...(typeof record.status === "number" ? { status: record.status } : {}),
35
+ message: typeof e.message === "string" && e.message
36
+ ? e.message
37
+ : "runtime snapshot request failed",
38
+ };
39
+ }
40
+ return {
41
+ ...(typeof record.status === "number" ? { status: record.status } : {}),
42
+ message: typeof record === "object" && "message" in record &&
43
+ typeof record.message === "string" &&
44
+ record.message
45
+ ? record.message
46
+ : "runtime snapshot request failed",
47
+ };
48
+ }
49
+ export function runtimeSnapshotReducer(state, action) {
50
+ switch (action.type) {
51
+ case "load-start":
52
+ // Stale replies are dropped by the fetch layer (sessionId guard); the
53
+ // reducer stays a deterministic state machine. The previous snapshot
54
+ // remains visible during a background refresh.
55
+ return {
56
+ ...state,
57
+ sessionId: action.sessionId,
58
+ status: "loading",
59
+ error: null,
60
+ };
61
+ case "load-success":
62
+ // RF-04: monotonic revision guard — a stale (lower-revision) reply for
63
+ // the current session must never overwrite a newer snapshot, so a slow
64
+ // refresh cannot reorder-overwrite the current session selection. The
65
+ // fetch layer already drops replies for other sessions (sessionId guard).
66
+ if (state.snapshot &&
67
+ state.sessionId === action.sessionId &&
68
+ action.snapshot.revision < state.snapshot.revision) {
69
+ return state;
70
+ }
71
+ return {
72
+ ...state,
73
+ sessionId: action.sessionId,
74
+ status: "ready",
75
+ snapshot: action.snapshot,
76
+ error: null,
77
+ reloadError: null,
78
+ reloading: false,
79
+ };
80
+ case "load-failure":
81
+ // AC-06: a failed load is a STRUCTURED error state — never folded
82
+ // into a fake empty snapshot.
83
+ return {
84
+ ...state,
85
+ sessionId: action.sessionId,
86
+ status: "error",
87
+ error: action.error,
88
+ snapshot: null,
89
+ };
90
+ case "reload-start":
91
+ // Single-flight: a reload already in progress is NOT queued (AC-06).
92
+ if (state.reloading)
93
+ return state;
94
+ return {
95
+ ...state,
96
+ reloading: true,
97
+ reloadError: null,
98
+ ...(state.status === "error" ? { error: null } : {}),
99
+ };
100
+ case "reload-success":
101
+ // Atomic replacement: the new snapshot supersedes the old one and
102
+ // every open panel reads the same revision.
103
+ return {
104
+ ...state,
105
+ status: "ready",
106
+ snapshot: action.snapshot,
107
+ reloading: false,
108
+ reloadError: null,
109
+ error: null,
110
+ sessionId: action.snapshot.sessionId || state.sessionId,
111
+ };
112
+ case "reload-failure":
113
+ return {
114
+ ...state,
115
+ reloading: false,
116
+ reloadError: action.error,
117
+ };
118
+ case "mutation-start":
119
+ return {
120
+ ...state,
121
+ mutationBusy: true,
122
+ mutationToken: action.token,
123
+ };
124
+ case "mutation-adopt":
125
+ // RF2-02: atomic adoption of a mutation response snapshot. Three
126
+ // guards are evaluated BEFORE any write:
127
+ // - mutationToken: only the LATEST mutation may adopt — a stale
128
+ // out-of-order response is dropped entirely;
129
+ // - sessionId: never adopt a mutation response into a state that
130
+ // belongs to a different session;
131
+ // - revision monotonic: equal/higher revisions adopt, lower reject
132
+ // (a stale reply can never reorder-overwrite the selection).
133
+ // The action NEVER settles mutationBusy (mutation-settle owns that)
134
+ // and never passes through an intermediate stale/loading state.
135
+ if (state.mutationToken !== action.token)
136
+ return state;
137
+ if (state.sessionId !== undefined &&
138
+ state.sessionId !== action.sessionId)
139
+ return state;
140
+ if (state.snapshot &&
141
+ action.snapshot.revision < state.snapshot.revision)
142
+ return state;
143
+ return {
144
+ ...state,
145
+ sessionId: action.sessionId,
146
+ status: "ready",
147
+ snapshot: action.snapshot,
148
+ error: null,
149
+ reloadError: null,
150
+ reloading: false,
151
+ };
152
+ case "mutation-settle":
153
+ // RF-04: only the LATEST request token settles the busy state; a
154
+ // stale (earlier) settle must not clear busy while a newer mutation
155
+ // is still in flight.
156
+ if (state.mutationToken !== action.token)
157
+ return state;
158
+ return {
159
+ ...state,
160
+ mutationBusy: false,
161
+ mutationToken: null,
162
+ };
163
+ case "mutation-reset":
164
+ // RF3-02: a session switch immediately abandons the in-flight
165
+ // mutation lifecycle — the new session must not inherit the old
166
+ // session's busy state. The adopted snapshot/status stay untouched
167
+ // (a load-start for the new session follows); stale adopt/settle
168
+ // actions then become no-ops via the token + sessionId guards.
169
+ return {
170
+ ...state,
171
+ mutationBusy: false,
172
+ mutationToken: null,
173
+ };
174
+ case "reset":
175
+ return { ...runtimeSnapshotInitialState };
176
+ default:
177
+ return state;
178
+ }
179
+ }
180
+ /**
181
+ * Derive the session/runtimeContext selection patch from an ADOPTED snapshot
182
+ * (RF3-01). The hydrate effect in `useRuntimeControls` feeds this patch into
183
+ * `mergeSessionRuntime` / `runtimeContext`, so derived state only ever comes
184
+ * from a snapshot the reducer guards accepted — the mutation success branch
185
+ * never writes session/runtimeContext directly. Missing fields are omitted;
186
+ * `auto` resolves to the Pi effective thinking level.
187
+ */
188
+ export function runtimeSelectionFromSnapshot(snapshot) {
189
+ const selection = snapshot.selection;
190
+ return {
191
+ ...(selection.model
192
+ ? {
193
+ model: {
194
+ provider: selection.model.provider,
195
+ modelId: selection.model.id,
196
+ },
197
+ }
198
+ : {}),
199
+ ...(selection.thinkingSelection === "auto"
200
+ ? selection.effectiveThinkingLevel
201
+ ? { thinkingLevel: selection.effectiveThinkingLevel }
202
+ : {}
203
+ : { thinkingLevel: selection.thinkingSelection }),
204
+ };
205
+ }
206
+ /**
207
+ * RF4: merge an incoming `/runtime-context` response with the ADOPTED snapshot
208
+ * (coordination seam genuinely called by the production `useRuntimeControls`
209
+ * context hydration effect; tests import this same symbol — no copied logic).
210
+ *
211
+ * Content fields (systemPrompt/resources/suffix/readOnly) always come from the
212
+ * response. model/thinkingLevel NEVER come from the response body — the
213
+ * backend returns them without revision semantics, so a late reply for the
214
+ * same session could carry a stale selection. Selection is derived exclusively
215
+ * from the adopted snapshot when it matches the current session (same
216
+ * derivation as the snapshot hydration effect: `runtimeSelectionFromSnapshot`,
217
+ * `auto` resolves to the Pi effective level); otherwise the current derived
218
+ * values are preserved, or omitted when absent. The store is never written
219
+ * here, so the snapshot hydration effect never re-runs.
220
+ */
221
+ export function mergeRuntimeContextWithSnapshot(current, context, snapshot, sessionId) {
222
+ const selection = snapshot && snapshot.sessionId === sessionId
223
+ ? runtimeSelectionFromSnapshot(snapshot)
224
+ : {};
225
+ const model = selection.model ?? current?.model;
226
+ const thinkingLevel = selection.thinkingLevel ?? current?.thinkingLevel;
227
+ return {
228
+ readOnly: context.readOnly,
229
+ systemPrompt: context.systemPrompt,
230
+ resources: context.resources,
231
+ suffix: context.suffix,
232
+ ...(model ? { model } : {}),
233
+ ...(thinkingLevel ? { thinkingLevel } : {}),
234
+ };
235
+ }
236
+ /** Create a fresh snapshot store (framework-free; React binding adds fetch). */
237
+ export function createRuntimeSnapshotStore(initial = runtimeSnapshotInitialState) {
238
+ let state = initial;
239
+ const listeners = new Set();
240
+ return {
241
+ getState: () => state,
242
+ dispatch: (action) => {
243
+ state = runtimeSnapshotReducer(state, action);
244
+ for (const listener of listeners)
245
+ listener();
246
+ return state;
247
+ },
248
+ subscribe: (listener) => {
249
+ listeners.add(listener);
250
+ return () => listeners.delete(listener);
251
+ },
252
+ };
253
+ }
254
+ /** True when the given error is the busy 409 (PI_SESSION_BUSY). */
255
+ export function isSnapshotBusyError(error) {
256
+ return error?.code === "PI_SESSION_BUSY";
257
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Owns title-only SSE lifecycles. It is deliberately independent from the
3
+ * timeline SSE applier: title frames update the session index only.
4
+ */
5
+ export class SessionTitleWatcher {
6
+ options;
7
+ eligible = new Set();
8
+ controllers = new Map();
9
+ fetchFn;
10
+ backoffMs;
11
+ deadlineMs;
12
+ constructor(options) {
13
+ this.options = options;
14
+ this.fetchFn = options.fetchFn ?? fetch;
15
+ this.backoffMs = options.backoffMs ?? 1_000;
16
+ this.deadlineMs = options.deadlineMs ?? 30_000;
17
+ }
18
+ markEligible(sessionId) {
19
+ this.eligible.add(sessionId);
20
+ }
21
+ startIfEligible(sessionId) {
22
+ if (!this.eligible.delete(sessionId) || this.controllers.has(sessionId))
23
+ return;
24
+ const controller = new AbortController();
25
+ this.controllers.set(sessionId, controller);
26
+ void this.run(sessionId, controller);
27
+ }
28
+ cancel(sessionId) {
29
+ this.eligible.delete(sessionId);
30
+ this.controllers.get(sessionId)?.abort();
31
+ this.controllers.delete(sessionId);
32
+ }
33
+ reconcile(sessionIds) {
34
+ const current = new Set(sessionIds);
35
+ for (const sessionId of this.controllers.keys()) {
36
+ if (!current.has(sessionId))
37
+ this.cancel(sessionId);
38
+ }
39
+ }
40
+ dispose() {
41
+ for (const controller of this.controllers.values())
42
+ controller.abort();
43
+ this.controllers.clear();
44
+ this.eligible.clear();
45
+ }
46
+ async run(sessionId, controller) {
47
+ let lastEventId;
48
+ const seen = new Set();
49
+ let deadlineReached = false;
50
+ const deadline = window.setTimeout(() => {
51
+ deadlineReached = true;
52
+ controller.abort();
53
+ }, this.deadlineMs);
54
+ try {
55
+ while (!controller.signal.aborted) {
56
+ try {
57
+ const headers = { Accept: "text/event-stream" };
58
+ if (lastEventId)
59
+ headers["Last-Event-ID"] = lastEventId;
60
+ const response = await this.fetchFn(`${this.options.origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}/events`, { headers, credentials: "include", signal: controller.signal });
61
+ if (response.status === 404 || !response.body)
62
+ return;
63
+ if (!response.ok)
64
+ throw new Error(`HTTP ${response.status}`);
65
+ const reader = response.body.getReader();
66
+ const decoder = new TextDecoder();
67
+ let buffer = "";
68
+ for (;;) {
69
+ const { done, value } = await reader.read();
70
+ if (done || controller.signal.aborted)
71
+ break;
72
+ buffer += decoder.decode(value, { stream: true });
73
+ const blocks = buffer.split("\n\n");
74
+ buffer = blocks.pop() ?? "";
75
+ for (const block of blocks) {
76
+ const eventId = block.split("\n").find((line) => line.startsWith("id: "))?.slice(4);
77
+ if (eventId)
78
+ lastEventId = eventId;
79
+ if (!eventId || seen.has(eventId) || !block.includes("event: session_title"))
80
+ continue;
81
+ const data = block.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
82
+ if (!data)
83
+ continue;
84
+ try {
85
+ const parsed = JSON.parse(data);
86
+ const title = parsed.data?.title?.trim();
87
+ if (!title)
88
+ continue;
89
+ seen.add(eventId);
90
+ if (!this.options.hasSession(sessionId))
91
+ return;
92
+ this.options.onTitle(sessionId, { title, automatic: Boolean(parsed.data?.automatic) });
93
+ if (parsed.data?.automatic)
94
+ return;
95
+ }
96
+ catch { /* malformed title frames are ignored */ }
97
+ }
98
+ }
99
+ }
100
+ catch {
101
+ if (controller.signal.aborted)
102
+ return;
103
+ }
104
+ if (!controller.signal.aborted)
105
+ await abortableSleep(this.backoffMs, controller.signal);
106
+ }
107
+ }
108
+ finally {
109
+ window.clearTimeout(deadline);
110
+ if (this.controllers.get(sessionId) === controller)
111
+ this.controllers.delete(sessionId);
112
+ if (deadlineReached || !this.options.hasSession(sessionId))
113
+ void this.options.refresh();
114
+ }
115
+ }
116
+ }
117
+ function abortableSleep(ms, signal) {
118
+ return new Promise((resolve) => {
119
+ const timer = window.setTimeout(done, ms);
120
+ const onAbort = () => done();
121
+ function done() {
122
+ window.clearTimeout(timer);
123
+ signal.removeEventListener("abort", onAbort);
124
+ resolve();
125
+ }
126
+ signal.addEventListener("abort", onAbort, { once: true });
127
+ });
128
+ }
@@ -0,0 +1,90 @@
1
+ /** Session/repo vertical split percent with versioned localStorage. */
2
+ export const SIDEBAR_SPLIT_STORAGE_KEY = "loop-console.operator-chat.sidebar-split.v1";
3
+ export const SIDEBAR_SPLIT_DEFAULT = 45;
4
+ export const SIDEBAR_SPLIT_MIN = 28;
5
+ export const SIDEBAR_SPLIT_MAX = 70;
6
+ export const SIDEBAR_SPLIT_STEP = 5;
7
+ export const SIDEBAR_SPLIT_STEP_LARGE = 10;
8
+ /** Clamp session share percent into the legal 28–70 band. */
9
+ export function clampSplit(percent) {
10
+ if (!Number.isFinite(percent))
11
+ return SIDEBAR_SPLIT_DEFAULT;
12
+ return Math.min(SIDEBAR_SPLIT_MAX, Math.max(SIDEBAR_SPLIT_MIN, Math.round(percent)));
13
+ }
14
+ /** Parse a stored value; only integers in 28–70 are accepted. */
15
+ export function parseStoredSplit(raw) {
16
+ if (raw == null || raw === "")
17
+ return null;
18
+ const n = Number(raw);
19
+ if (!Number.isFinite(n))
20
+ return null;
21
+ const rounded = Math.round(n);
22
+ if (rounded < SIDEBAR_SPLIT_MIN || rounded > SIDEBAR_SPLIT_MAX)
23
+ return null;
24
+ return rounded;
25
+ }
26
+ /** Load session percent or fall back to 45 on missing/corrupt storage. */
27
+ export function loadSidebarSplit(storage = defaultStorage()) {
28
+ if (!storage)
29
+ return SIDEBAR_SPLIT_DEFAULT;
30
+ try {
31
+ const parsed = parseStoredSplit(storage.getItem(SIDEBAR_SPLIT_STORAGE_KEY));
32
+ return parsed ?? SIDEBAR_SPLIT_DEFAULT;
33
+ }
34
+ catch {
35
+ return SIDEBAR_SPLIT_DEFAULT;
36
+ }
37
+ }
38
+ /** Persist a clamped session percent; ignore storage failures. */
39
+ export function saveSidebarSplit(percent, storage = defaultStorage()) {
40
+ const value = clampSplit(percent);
41
+ if (!storage)
42
+ return value;
43
+ try {
44
+ storage.setItem(SIDEBAR_SPLIT_STORAGE_KEY, String(value));
45
+ }
46
+ catch {
47
+ // localStorage may be unavailable; keep in-memory value.
48
+ }
49
+ return value;
50
+ }
51
+ /** Map keyboard keys on the separator to a next session percent. */
52
+ export function adjustSplitByKey(current, key, shiftKey = false) {
53
+ const base = clampSplit(current);
54
+ switch (key) {
55
+ case "ArrowUp":
56
+ return clampSplit(base - (shiftKey ? SIDEBAR_SPLIT_STEP_LARGE : SIDEBAR_SPLIT_STEP));
57
+ case "ArrowDown":
58
+ return clampSplit(base + (shiftKey ? SIDEBAR_SPLIT_STEP_LARGE : SIDEBAR_SPLIT_STEP));
59
+ case "Home":
60
+ return SIDEBAR_SPLIT_MIN;
61
+ case "End":
62
+ return SIDEBAR_SPLIT_MAX;
63
+ case "Enter":
64
+ return SIDEBAR_SPLIT_DEFAULT;
65
+ default:
66
+ return null;
67
+ }
68
+ }
69
+ /**
70
+ * Compute session percent from a pointer Y within a split track rect.
71
+ * top of track → min session share; bottom → max session share.
72
+ */
73
+ export function splitPercentFromPointer(clientY, trackTop, trackHeight) {
74
+ if (!Number.isFinite(trackHeight) || trackHeight <= 0) {
75
+ return SIDEBAR_SPLIT_DEFAULT;
76
+ }
77
+ const ratio = (clientY - trackTop) / trackHeight;
78
+ return clampSplit(ratio * 100);
79
+ }
80
+ function defaultStorage() {
81
+ try {
82
+ if (typeof globalThis === "undefined")
83
+ return null;
84
+ const storage = globalThis.localStorage;
85
+ return storage ?? null;
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Mutual-exclusion state for large spatial overlays in Operator Chat.
3
+ * At most one of: sidebar drawer, process, runtime context, tools.
4
+ * Small composer menus (model/thinking) are out of scope.
5
+ */
6
+ export function createSpatialOverlayState(active = "none") {
7
+ return { active };
8
+ }
9
+ /** Open one overlay; closes any other spatial overlay. */
10
+ export function openSpatialOverlay(_state, kind) {
11
+ return { active: kind };
12
+ }
13
+ /** Close the active overlay (or no-op when already none). */
14
+ export function closeSpatialOverlay(_state = { active: "none" }) {
15
+ return { active: "none" };
16
+ }
17
+ /** Close only when the named overlay is active. */
18
+ export function closeSpatialOverlayIf(state, kind) {
19
+ return state.active === kind ? { active: "none" } : state;
20
+ }
21
+ export function isSpatialOverlayOpen(state, kind) {
22
+ if (kind)
23
+ return state.active === kind;
24
+ return state.active !== "none";
25
+ }
26
+ /**
27
+ * Derive exclusive UI flags for the four large panels from a single source.
28
+ * Opening one forces the others closed.
29
+ */
30
+ export function spatialOverlayFlags(state) {
31
+ return {
32
+ sidebarDrawerOpen: state.active === "sidebar-drawer",
33
+ processOpen: state.active === "process",
34
+ runtimeContextOpen: state.active === "runtime-context",
35
+ toolsOpen: state.active === "tools",
36
+ };
37
+ }