@tea-agent/loop-agent 0.33.6 → 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 (26) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/worker/console/chat/model-resolver.js +17 -0
  3. package/dist/worker/console/chat/pi-runtime.js +397 -132
  4. package/dist/worker/console/chat/routes.js +185 -25
  5. package/dist/worker/console/chat/session-store.js +39 -0
  6. package/dist/worker/console/static/assets/index-BQkhJpV8.css +1 -0
  7. package/dist/worker/console/static/assets/index-CMHovlqG.js +32 -0
  8. package/dist/worker/console/static/index.html +2 -2
  9. package/dist/worker/console/static-src/operator-chat/landing-density.js +23 -0
  10. package/dist/worker/console/static-src/operator-chat/session-title-watcher.js +128 -0
  11. package/dist/worker/console/static-src/operator-chat/sidebar-split.js +90 -0
  12. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +37 -0
  13. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +109 -22
  14. package/dist/worker/console/static-src/operator-chat/useChatStream.js +6 -1
  15. package/dist/worker/console/static-src/operator-chat/useOverlayFocus.js +84 -0
  16. package/dist/worker/console/static-src/operator-chat/useWorkspaceLayout.js +58 -0
  17. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +31 -0
  18. package/harness.json +1 -1
  19. package/package.json +1 -1
  20. package/skills/local-jacoco-coverage/SKILL.md +281 -0
  21. package/skills/local-jacoco-coverage/references/requirement-to-source-mapping.md +85 -0
  22. package/skills/local-jacoco-coverage/references/runtime-alignment.md +106 -0
  23. package/skills/local-jacoco-coverage/scripts/run-coverage-analysis.sh +148 -0
  24. package/skills/local-jacoco-coverage/scripts/start-jacoco-agent.sh +110 -0
  25. package/dist/worker/console/static/assets/index-BUOLppPr.js +0 -28
  26. package/dist/worker/console/static/assets/index-C1KzazY5.css +0 -1
@@ -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-BUOLppPr.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-C1KzazY5.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>
@@ -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
+ }
@@ -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
+ }
@@ -4,6 +4,56 @@ import { hydrateSessionThread } from "../../chat/turn-process.js";
4
4
  import { ACTIVE_CHAT_SESSION_STORAGE_KEY } from "../../chat/workspace-landing.js";
5
5
  import { CHAT_SESSION_LIST_LIMIT } from "../chat-view-types.js";
6
6
  import { confirmationToken } from "./format.js";
7
+ import { SessionTitleWatcher } from "./session-title-watcher.js";
8
+ export async function createSessionAndPrimeTitleWatcher(input) {
9
+ if (input.creatingRef?.current) {
10
+ throw new Error("CREATE_SESSION_IN_FLIGHT");
11
+ }
12
+ if (input.creatingRef)
13
+ input.creatingRef.current = true;
14
+ // Synchronous busy feedback: callers set creatingSession in the same render
15
+ // turn via setCreatingSession before the network await below.
16
+ input.setCreatingSession?.(true);
17
+ try {
18
+ const res = await (input.fetchFn ?? fetch)(`${input.origin}/api/operator/v1/chat/sessions`, {
19
+ method: "POST",
20
+ headers: {
21
+ "Content-Type": "application/json",
22
+ "x-loop-console-confirmation": confirmationToken(),
23
+ },
24
+ credentials: "include",
25
+ body: JSON.stringify(input.options ?? {}),
26
+ });
27
+ if (!res.ok) {
28
+ const body = (await res.json().catch(() => ({})));
29
+ throw new Error(body.error?.message ?? `HTTP ${res.status}`);
30
+ }
31
+ const body = (await res.json());
32
+ if (!body.session)
33
+ throw new Error("创建会话响应缺少 durable session");
34
+ const durable = body.session;
35
+ const created = {
36
+ sessionId: durable.sessionId,
37
+ ...runtimeSelectionFromRecord(durable),
38
+ };
39
+ const nextList = [
40
+ durable,
41
+ ...input.sessionListRef.current.filter((item) => item.sessionId !== durable.sessionId),
42
+ ].slice(0, CHAT_SESSION_LIST_LIMIT);
43
+ // Commit ref → state → eligibility → deferred refresh. The ref assignment
44
+ // makes the durable record observable before React's state commit.
45
+ input.sessionListRef.current = nextList;
46
+ input.setSessionList(nextList);
47
+ input.titleWatcher?.markEligible(durable.sessionId);
48
+ void input.refreshSessionList();
49
+ return created;
50
+ }
51
+ finally {
52
+ if (input.creatingRef)
53
+ input.creatingRef.current = false;
54
+ input.setCreatingSession?.(false);
55
+ }
56
+ }
7
57
  /** Session lifecycle: list / activate / create / delete / rename-archive /
8
58
  * compact, plus browser-refresh recovery from the durable session store. */
9
59
  export function useChatSessions(params) {
@@ -12,6 +62,12 @@ export function useChatSessions(params) {
12
62
  const { setInput, setPendingImages } = composer;
13
63
  const { setPreviews, setActivePreviewPath } = browser;
14
64
  const [sessionList, setSessionList] = useState([]);
65
+ const sessionListRef = useRef([]);
66
+ sessionListRef.current = sessionList;
67
+ const titleWatcherRef = useRef(null);
68
+ // Shared busy gate for sidebar + Landing create entries (one render feedback).
69
+ const [creatingSession, setCreatingSession] = useState(false);
70
+ const creatingSessionRef = useRef(false);
15
71
  // Only the latest activate/create/restore intent may commit active-session
16
72
  // state. This prevents slow responses from an older selection overwriting a
17
73
  // newer user choice.
@@ -102,6 +158,7 @@ export function useChatSessions(params) {
102
158
  setError("删除会话失败");
103
159
  return;
104
160
  }
161
+ titleWatcherRef.current?.cancel(sessionId);
105
162
  if (refs.sessionRef.current?.sessionId === sessionId) {
106
163
  sessionSelectionVersionRef.current += 1;
107
164
  refs.sessionRef.current = null;
@@ -179,6 +236,32 @@ export function useChatSessions(params) {
179
236
  useEffect(() => {
180
237
  void refreshSessionList();
181
238
  }, [refreshSessionList]);
239
+ if (!titleWatcherRef.current) {
240
+ titleWatcherRef.current = new SessionTitleWatcher({
241
+ origin,
242
+ hasSession: (sessionId) => sessionListRef.current.some((item) => item.sessionId === sessionId),
243
+ onTitle: (sessionId, event) => {
244
+ setSessionList((current) => current.map((item) => {
245
+ if (item.sessionId !== sessionId)
246
+ return item;
247
+ // A manually assigned title is authoritative over late automatic output.
248
+ if (event.automatic && item.title)
249
+ return item;
250
+ return event.automatic
251
+ ? { ...item, title: event.title }
252
+ : { ...item, displayTitle: event.title };
253
+ }));
254
+ },
255
+ refresh: refreshSessionList,
256
+ });
257
+ }
258
+ const watchSessionTitle = useCallback((sessionId) => titleWatcherRef.current?.startIfEligible(sessionId), []);
259
+ useEffect(() => {
260
+ titleWatcherRef.current?.reconcile(sessionList
261
+ .filter((item) => item.state !== "archived" && !item.title)
262
+ .map((item) => item.sessionId));
263
+ }, [sessionList]);
264
+ useEffect(() => () => titleWatcherRef.current?.dispose(), []);
182
265
  // Browser refresh recovery: retain only the durable session id locally, then
183
266
  // re-read messages and operation refs/facts from the server-owned stores.
184
267
  useEffect(() => {
@@ -233,29 +316,26 @@ export function useChatSessions(params) {
233
316
  cancelled = true;
234
317
  };
235
318
  }, [origin, refs, setSession, setInput, setMessages]);
319
+ // The sole production create path delegates to the exported orchestration
320
+ // helper so its durable commit order is directly traceable and testable.
321
+ const createSessionAndPrimeTitleWatcherForHook = useCallback((options) => createSessionAndPrimeTitleWatcher({
322
+ origin,
323
+ sessionListRef,
324
+ setSessionList,
325
+ titleWatcher: titleWatcherRef.current,
326
+ refreshSessionList,
327
+ options,
328
+ setCreatingSession,
329
+ creatingRef: creatingSessionRef,
330
+ }), [origin, refreshSessionList]);
236
331
  const createSession = useCallback(async (options) => {
332
+ // Suppress concurrent create from any entry (sidebar / Landing / chip).
333
+ if (creatingSessionRef.current)
334
+ return null;
237
335
  const selectionVersion = ++sessionSelectionVersionRef.current;
238
336
  setError(null);
239
337
  try {
240
- const res = await fetch(`${origin}/api/operator/v1/chat/sessions`, {
241
- method: "POST",
242
- headers: {
243
- "Content-Type": "application/json",
244
- "x-loop-console-confirmation": confirmationToken(),
245
- },
246
- credentials: "include",
247
- body: JSON.stringify(options ?? {}),
248
- });
249
- if (!res.ok) {
250
- const body = (await res.json().catch(() => ({})));
251
- throw new Error(body.error?.message ?? `HTTP ${res.status}`);
252
- }
253
- const body = (await res.json());
254
- const created = {
255
- sessionId: body.sessionId,
256
- ...(body.model ? { model: body.model } : {}),
257
- };
258
- void refreshSessionList();
338
+ const created = await createSessionAndPrimeTitleWatcherForHook(options);
259
339
  if (selectionVersion !== sessionSelectionVersionRef.current)
260
340
  return null;
261
341
  resetActiveProjection();
@@ -270,16 +350,18 @@ export function useChatSessions(params) {
270
350
  catch (e) {
271
351
  if (selectionVersion !== sessionSelectionVersionRef.current)
272
352
  return null;
273
- setError(e instanceof Error ? e.message : String(e));
353
+ const message = e instanceof Error ? e.message : String(e);
354
+ if (message !== "CREATE_SESSION_IN_FLIGHT") {
355
+ setError(message);
356
+ }
274
357
  return null;
275
358
  }
276
359
  }, [
277
- origin,
278
360
  refs,
279
361
  setSession,
280
362
  setError,
281
363
  resetActiveProjection,
282
- refreshSessionList,
364
+ createSessionAndPrimeTitleWatcherForHook,
283
365
  ]);
284
366
  // Phase-1: fork / in-session branch / mainline switch UI deferred; API routes remain.
285
367
  const compactSession = useCallback(async () => {
@@ -318,6 +400,9 @@ export function useChatSessions(params) {
318
400
  return;
319
401
  }
320
402
  await refreshSessionList();
403
+ if (patch.state === "archived") {
404
+ titleWatcherRef.current?.cancel(sessionId);
405
+ }
321
406
  if (patch.state === "archived" &&
322
407
  refs.sessionRef.current?.sessionId === sessionId) {
323
408
  sessionSelectionVersionRef.current += 1;
@@ -329,11 +414,13 @@ export function useChatSessions(params) {
329
414
  };
330
415
  return {
331
416
  sessionList,
417
+ creatingSession,
332
418
  activateSession,
333
419
  refreshSessionList,
334
420
  deleteSession,
335
421
  createSession,
336
422
  updateSession,
423
+ watchSessionTitle,
337
424
  compactSession,
338
425
  };
339
426
  }
@@ -14,7 +14,7 @@ function sleepBriefly() {
14
14
  /** One GET /events stream owns all Chat events. Sending a prompt creates a
15
15
  * detached Turn resource only; it never opens a second inline SSE consumer. */
16
16
  export function useChatStream(params) {
17
- const { origin, refs, session, thread, composer, setError, setAutoScroll, recoveryVersion = 0, } = params;
17
+ const { origin, refs, session, thread, composer, setError, setAutoScroll, recoveryVersion = 0, watchSessionTitle, } = params;
18
18
  const { applySseBlock, setMessages, setStreaming, setStreamingAssistantId, streaming, } = thread;
19
19
  const { setInput, setPendingImages } = composer;
20
20
  /** Follow one session's event stream until it is explicitly aborted. The
@@ -185,6 +185,10 @@ export function useChatStream(params) {
185
185
  const body = (await res.json().catch(() => ({})));
186
186
  if (!res.ok || !body.turnId)
187
187
  throw new Error(body.error?.message ?? `HTTP ${res.status}`);
188
+ // A successful accepted turn is the browser-side authorization to
189
+ // watch this session's late automatic title. The watcher is independent
190
+ // of the active-session generation, so switching away does not lose it.
191
+ watchSessionTitle(activeSession.sessionId);
188
192
  // Slow POST returns cannot alter a later selection/turn (M5.2).
189
193
  if (refs.sessionRef.current?.sessionId !== activeSession.sessionId ||
190
194
  refs.sessionGenerationRef.current !== generation)
@@ -221,6 +225,7 @@ export function useChatStream(params) {
221
225
  setStreaming,
222
226
  setStreamingAssistantId,
223
227
  setPendingImages,
228
+ watchSessionTitle,
224
229
  ]);
225
230
  const handleStop = useCallback(() => {
226
231
  refs.abortRef.current?.abort();
@@ -0,0 +1,84 @@
1
+ import { useEffect, useRef } from "react";
2
+ const FOCUSABLE = 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
3
+ /**
4
+ * Trap focus inside an open overlay root; restore focus to the prior trigger
5
+ * on close. Escape is handled by the caller (or optional onEscape).
6
+ *
7
+ * Restore contract: open true→false and unmount-while-open share a single
8
+ * restore path (cleanup). A wasOpen flag prevents double restore when the
9
+ * transition effect re-runs after cleanup.
10
+ */
11
+ export function useOverlayFocus(options) {
12
+ const { open, containerRef, triggerRef, onEscape } = options;
13
+ const previousFocusRef = useRef(null);
14
+ const wasOpenRef = useRef(false);
15
+ useEffect(() => {
16
+ if (open) {
17
+ if (!wasOpenRef.current) {
18
+ const active = document.activeElement;
19
+ previousFocusRef.current =
20
+ triggerRef?.current ??
21
+ (active instanceof HTMLElement ? active : null);
22
+ const root = containerRef.current;
23
+ if (root) {
24
+ const first = root.querySelector(FOCUSABLE);
25
+ (first ?? root).focus?.();
26
+ }
27
+ wasOpenRef.current = true;
28
+ }
29
+ // Single restore path for true→false (cleanup before next effect)
30
+ // and for unmount-while-open (cleanup on unmount).
31
+ return () => {
32
+ if (!wasOpenRef.current)
33
+ return;
34
+ const restore = triggerRef?.current ?? previousFocusRef.current;
35
+ if (restore && document.contains(restore)) {
36
+ restore.focus();
37
+ }
38
+ previousFocusRef.current = null;
39
+ wasOpenRef.current = false;
40
+ };
41
+ }
42
+ // open === false: ensure flag is clear; restore already handled by
43
+ // the previous open-effect cleanup when transitioning true→false.
44
+ wasOpenRef.current = false;
45
+ return undefined;
46
+ }, [open, containerRef, triggerRef]);
47
+ useEffect(() => {
48
+ if (!open)
49
+ return;
50
+ const root = containerRef.current;
51
+ if (!root)
52
+ return;
53
+ const onKeyDown = (event) => {
54
+ if (event.key === "Escape") {
55
+ event.preventDefault();
56
+ onEscape?.();
57
+ return;
58
+ }
59
+ if (event.key !== "Tab")
60
+ return;
61
+ const nodes = Array.from(root.querySelectorAll(FOCUSABLE)).filter((el) => !el.hasAttribute("disabled") && el.tabIndex !== -1);
62
+ if (nodes.length === 0) {
63
+ event.preventDefault();
64
+ root.focus?.();
65
+ return;
66
+ }
67
+ const first = nodes[0];
68
+ const last = nodes[nodes.length - 1];
69
+ const active = document.activeElement;
70
+ if (event.shiftKey) {
71
+ if (active === first || !root.contains(active)) {
72
+ event.preventDefault();
73
+ last.focus();
74
+ }
75
+ }
76
+ else if (active === last || !root.contains(active)) {
77
+ event.preventDefault();
78
+ first.focus();
79
+ }
80
+ };
81
+ document.addEventListener("keydown", onKeyDown);
82
+ return () => document.removeEventListener("keydown", onKeyDown);
83
+ }, [open, containerRef, onEscape]);
84
+ }
@@ -0,0 +1,58 @@
1
+ import { useEffect, useState } from "react";
2
+ import { resolveWorkspaceLayoutMode, } from "./workspace-layout-mode.js";
3
+ import { resolveLandingDensity, } from "./landing-density.js";
4
+ const INITIAL = {
5
+ mode: "comfortable",
6
+ width: 1600,
7
+ landingDensity: "comfortable",
8
+ emptyHeight: 720,
9
+ };
10
+ /**
11
+ * Single ResizeObserver adapter for `.oc-workspace` width and optional empty
12
+ * messages height. Mode is never derived from UA or window.innerWidth alone.
13
+ */
14
+ export function useWorkspaceLayout(workspaceRef, emptyMessagesRef) {
15
+ const [snapshot, setSnapshot] = useState(INITIAL);
16
+ useEffect(() => {
17
+ const workspace = workspaceRef.current;
18
+ if (!workspace || typeof ResizeObserver === "undefined") {
19
+ return;
20
+ }
21
+ const measure = () => {
22
+ const width = workspace.getBoundingClientRect().width;
23
+ const emptyEl = emptyMessagesRef?.current ?? null;
24
+ const emptyHeight = emptyEl
25
+ ? emptyEl.getBoundingClientRect().height
26
+ : 720;
27
+ setSnapshot({
28
+ mode: resolveWorkspaceLayoutMode(width),
29
+ width,
30
+ landingDensity: resolveLandingDensity(emptyHeight),
31
+ emptyHeight,
32
+ });
33
+ };
34
+ measure();
35
+ const ro = new ResizeObserver(() => measure());
36
+ ro.observe(workspace);
37
+ // Poll briefly for empty-messages node mount without depending on its
38
+ // identity as a React effect dependency.
39
+ let emptyObserved = null;
40
+ const tryObserveEmpty = () => {
41
+ const emptyEl = emptyMessagesRef?.current ?? null;
42
+ if (emptyEl && emptyEl !== emptyObserved) {
43
+ if (emptyObserved)
44
+ ro.unobserve(emptyObserved);
45
+ ro.observe(emptyEl);
46
+ emptyObserved = emptyEl;
47
+ measure();
48
+ }
49
+ };
50
+ tryObserveEmpty();
51
+ const interval = window.setInterval(tryObserveEmpty, 250);
52
+ return () => {
53
+ window.clearInterval(interval);
54
+ ro.disconnect();
55
+ };
56
+ }, [workspaceRef, emptyMessagesRef]);
57
+ return snapshot;
58
+ }