@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.
- package/CHANGELOG.md +38 -17
- package/dist/application/task-lifecycle/advance.js +254 -4
- package/dist/application/task-lifecycle/gates.js +50 -0
- package/dist/application/task-lifecycle/observe.js +11 -2
- package/dist/commands/init-upgrade.js +32 -1
- package/dist/commands/init.js +94 -3
- package/dist/executors/shell-executor.js +4 -91
- package/dist/executors/shell-write-guard.js +26 -8
- package/dist/shared/operator/capabilities.js +72 -42
- package/dist/shared/resilient-git.js +133 -0
- package/dist/task/source-prepare/artifact-meta.js +137 -0
- package/dist/task/source-prepare/index.js +2 -0
- package/dist/task/source-prepare/parse-intent.js +58 -10
- package/dist/task/source-prepare/prepare.js +180 -16
- package/dist/task/source-prepare/reference-integrity.js +18 -2
- package/dist/task/source-prepare/semantic-intake.js +404 -0
- package/dist/worker/console/app-data.js +2 -0
- package/dist/worker/console/chat/chat-event-store.js +190 -25
- package/dist/worker/console/chat/model-resolver.js +17 -0
- package/dist/worker/console/chat/pi-console-config.js +250 -32
- package/dist/worker/console/chat/pi-runtime.js +1007 -188
- package/dist/worker/console/chat/resource-loader.js +5 -4
- package/dist/worker/console/chat/routes.js +495 -157
- package/dist/worker/console/chat/runtime-context.js +48 -12
- package/dist/worker/console/chat/runtime-selection.js +59 -0
- package/dist/worker/console/chat/session-store.js +39 -0
- package/dist/worker/console/chat/shortcuts.js +1 -0
- package/dist/worker/console/chat/tool-adapter.js +9 -3
- package/dist/worker/console/chat/tools.js +5 -1
- package/dist/worker/console/dag-execution-receipt.js +380 -0
- package/dist/worker/console/operator-actions.js +559 -68
- package/dist/worker/console/server.js +8 -15
- package/dist/worker/console/static/assets/index-BQkhJpV8.css +1 -0
- package/dist/worker/console/static/assets/index-CMHovlqG.js +32 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +45 -8
- package/dist/worker/console/static-src/operator-chat/landing-density.js +23 -0
- package/dist/worker/console/static-src/operator-chat/refs.js +9 -0
- package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +257 -0
- package/dist/worker/console/static-src/operator-chat/session-title-watcher.js +128 -0
- package/dist/worker/console/static-src/operator-chat/sidebar-split.js +90 -0
- package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +37 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +125 -22
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +215 -184
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +49 -5
- package/dist/worker/console/static-src/operator-chat/useComposer.js +17 -0
- package/dist/worker/console/static-src/operator-chat/useOverlayFocus.js +84 -0
- package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +225 -74
- package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +196 -0
- package/dist/worker/console/static-src/operator-chat/useWorkspaceLayout.js +58 -0
- package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +31 -0
- package/dist/worker/delivery/final-verification.js +13 -5
- package/dist/worker/delivery/package.js +31 -19
- package/dist/worker/delivery/verification-bundle.js +6 -4
- package/dist/worker/observe/static/operator-chrome.css +5 -2
- package/dist/worker/observe/static/operator-chrome.js +6 -1
- package/dist/worker/observe/static/styles.css +39 -9
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +33 -462
- package/dist/workflows/dag/backend-test-case-manifest.js +0 -4
- package/dist/workflows/dag/backend-test-markdown-workflow.js +1 -25
- package/dist/workflows/dag/backend-test-module-stem.js +0 -5
- package/dist/workflows/dag/backend-test-pytest-collection.js +24 -345
- package/dist/workflows/dag/backend-test-scenario-param.js +82 -269
- package/dist/workflows/dag/backend-test-writer-completeness.js +16 -47
- package/dist/workflows/dag/dynamic-runtime/map.js +8 -24
- package/dist/workflows/dag/frontend-worktree-diff.js +12 -27
- package/dist/workflows/dag/init-hybrid.js +34 -46
- package/dist/workflows/dag/types.js +0 -7
- package/dist/workflows/dag/workspace-checkpoint.js +8 -27
- package/docs/templates/backend-test-dag.json +29 -32
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/local-jacoco-coverage/SKILL.md +281 -0
- package/skills/local-jacoco-coverage/references/requirement-to-source-mapping.md +85 -0
- package/skills/local-jacoco-coverage/references/runtime-alignment.md +106 -0
- package/skills/local-jacoco-coverage/scripts/run-coverage-analysis.sh +148 -0
- package/skills/local-jacoco-coverage/scripts/start-jacoco-agent.sh +110 -0
- package/skills/loop-agent/references/command-reference.md +3 -1
- package/skills/loop-agent/references/source-and-plan-practice.md +13 -0
- package/skills/loop-agent/references/task-workflow.md +4 -0
- package/dist/worker/console/chat/instruction-skills.js +0 -217
- package/dist/worker/console/static/assets/index-CnUXAqxG.css +0 -1
- 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-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
|
17
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
//
|
|
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
|
+
}
|