@cueloop/client 0.1.0-alpha.97 → 0.1.0-alpha.99

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 (76) hide show
  1. package/package.json +11 -11
  2. package/src/annotations/components/AnnotationCards.test.tsx +42 -0
  3. package/src/annotations/components/AnnotationCards.tsx +31 -8
  4. package/src/annotations/use-annotation-surface.tsx +257 -31
  5. package/src/app/App.deleted-scroll.test.tsx +1 -0
  6. package/src/app/App.diff.test.tsx +1 -0
  7. package/src/app/App.identity.test.tsx +1 -0
  8. package/src/app/App.regression.test.tsx +5 -0
  9. package/src/app/App.share-dialog.test.tsx +5 -0
  10. package/src/app/App.submit-confirm.test.tsx +3 -0
  11. package/src/app/App.test.tsx +1 -0
  12. package/src/app/App.thread-agent.test.ts +12 -0
  13. package/src/app/App.tsx +223 -97
  14. package/src/app/App.visual.test.tsx +2 -0
  15. package/src/app/App.workbench.test.tsx +53 -0
  16. package/src/app/app-screens.tsx +3 -0
  17. package/src/app/components/AppShell.tsx +1 -0
  18. package/src/app/components/AppShell.zoom.test.tsx +4 -3
  19. package/src/app/intent-dispatch.test.ts +1 -0
  20. package/src/app/run.ts +1 -0
  21. package/src/diff/components/AnnotatableFileView.test.tsx +2 -0
  22. package/src/diff/components/AnnotatableFileView.tsx +8 -0
  23. package/src/diff/components/DiffContentView.scroll.test.tsx +1 -0
  24. package/src/diff/components/DiffContentView.tsx +10 -0
  25. package/src/diff/diff-intraline.test.ts +1 -0
  26. package/src/diff/diff-intraline.ts +2 -0
  27. package/src/diff/split-diff.ts +2 -0
  28. package/src/integrations/components/ShareDialog.tsx +1 -0
  29. package/src/markdown/components/ThreadView.tsx +62 -16
  30. package/src/markdown/mark-runs.ts +1 -0
  31. package/src/markdown/markdown-highlight.ts +2 -0
  32. package/src/performance/otlp-trace.test.ts +3 -0
  33. package/src/performance/otlp-trace.ts +4 -0
  34. package/src/performance/perf-report.ts +1 -0
  35. package/src/performance/perf-timings.ts +1 -0
  36. package/src/settings/components/quick-actions-editor.test.tsx +3 -0
  37. package/src/settings/config.test.ts +15 -0
  38. package/src/settings/config.ts +14 -4
  39. package/src/stories/__snapshots__/stories.test.tsx.snap +74 -0
  40. package/src/stories/stories-app.tsx +3 -0
  41. package/src/stories/story.ts +2 -0
  42. package/src/terminal/pty.ts +1 -0
  43. package/src/thread/agent-continuation.test.ts +32 -0
  44. package/src/thread/agent-continuation.ts +13 -0
  45. package/src/thread/agent-transcript.test.ts +161 -0
  46. package/src/thread/agent-transcript.ts +336 -0
  47. package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
  48. package/src/thread/components/AgentConfigControls.test.tsx +112 -0
  49. package/src/thread/components/AgentConfigControls.tsx +114 -0
  50. package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
  51. package/src/thread/components/AgentThreadPane.test.tsx +829 -0
  52. package/src/thread/components/AgentThreadPane.tsx +367 -0
  53. package/src/thread/components/ThreadFooter.test.tsx +2 -0
  54. package/src/thread/components/ThreadFooter.tsx +3 -0
  55. package/src/thread/components/ThreadTree.test.tsx +41 -0
  56. package/src/thread/components/ThreadTree.tsx +25 -5
  57. package/src/thread/components/thread-tree.test.ts +3 -0
  58. package/src/thread/components/thread-tree.ts +4 -0
  59. package/src/thread/thread-controller.curate.test.ts +1 -0
  60. package/src/thread/thread-controller.live-diff.test.ts +7 -0
  61. package/src/thread/thread-controller.share.test.ts +1 -0
  62. package/src/thread/thread-controller.ts +42 -0
  63. package/src/thread/use-thread-agent.test.tsx +208 -0
  64. package/src/thread/use-thread-agent.ts +111 -0
  65. package/src/ui/components/PromptDialog.test.tsx +4 -0
  66. package/src/ui/components/RootOverlay.tsx +1 -0
  67. package/src/ui/components/primitives/Dialog.test.tsx +1 -0
  68. package/src/workbench/components/ChangesColumn.tsx +1 -0
  69. package/src/workbench/components/ChangesFileTree.test.tsx +2 -0
  70. package/src/workbench/components/FileContentsView.tsx +2 -0
  71. package/src/workbench/components/GridTabContent.tsx +7 -0
  72. package/src/workbench/components/ProjectTreeView.test.tsx +3 -0
  73. package/src/workbench/components/ProjectTreeView.tsx +1 -0
  74. package/src/workbench/components/editor-grid.test.ts +11 -0
  75. package/src/workbench/components/editor-grid.ts +10 -0
  76. package/src/workbench/use-changes-workbench.ts +5 -0
@@ -36,7 +36,12 @@ import { wrapLines, type MarkRange, type VisualLine } from "../mark-runs";
36
36
  import { MarkdownGridBlock } from "./MarkdownGridBlock";
37
37
  import { useFrameMeasure } from "../../ui/use-frame-measure";
38
38
  import { scrollBoxDragViewport, useTerminalVirtualizer } from "../../ui/use-terminal-virtualizer";
39
- import { useAnnotationSurface, type LineSource } from "../../annotations/use-annotation-surface";
39
+ import {
40
+ useAnnotationSurface,
41
+ type LineSource,
42
+ type PromptFocusRequest,
43
+ type PromptRestoreRequest,
44
+ } from "../../annotations/use-annotation-surface";
40
45
  import { NavModeHint } from "../../keyboard/components/NavModeHint";
41
46
  import { DiscussionMarkerRail } from "../../annotations/components/DiscussionMarkerRail";
42
47
  import { useComponentTheme } from "../../appearance/components/theme-context";
@@ -150,6 +155,17 @@ export const THREAD_VIEW_CHEATSHEET: CheatsheetSection[] = [
150
155
  ];
151
156
 
152
157
  export interface ThreadViewProps {
158
+ /** Agent transcripts permit feedback only on finalized answer blocks. */
159
+ canAnnotateBlock?: (blockIndex: number) => boolean;
160
+ isAnnotationReadOnly?: (id: string) => boolean;
161
+ annotationAction?: (id: string) => { label: string; run: () => void } | undefined;
162
+ requestedBlock?: { blockIndex: number };
163
+ onInvoke?: () => void;
164
+ /** The final prompt line uses a plain composer instead of a comment card. */
165
+ isPromptBlock?: (blockIndex: number) => boolean;
166
+ promptFocusRequest?: PromptFocusRequest;
167
+ promptRestoreRequest?: PromptRestoreRequest;
168
+ renderBlock?: (index: number) => React.ReactNode | undefined;
153
169
  session: Thread;
154
170
  display: DisplayBlock[];
155
171
  marks: Map<number, Mark[]>;
@@ -202,13 +218,22 @@ export function ThreadView({
202
218
  onNavCommand,
203
219
  onExit,
204
220
  theme,
221
+ canAnnotateBlock,
222
+ isAnnotationReadOnly,
223
+ annotationAction,
224
+ requestedBlock,
225
+ onInvoke,
226
+ isPromptBlock,
227
+ promptFocusRequest,
228
+ promptRestoreRequest,
229
+ renderBlock,
205
230
  }: ThreadViewProps): React.ReactNode {
206
231
  const tokens = useComponentTheme(theme);
207
232
  const source: LineSource = {
208
233
  count: display.length,
209
234
  // the surface hit-tests and paints in rendered text (inline markers concealed); a grid anchors on its raw source
210
235
  textAt: (blockIndex) => renderedText(display[blockIndex]!),
211
- annotatable: () => true,
236
+ annotatable: canAnnotateBlock ?? (() => true),
212
237
  };
213
238
  const scrollRef = useRef<ScrollBoxRenderable | null>(null);
214
239
  const surface = useAnnotationSurface({
@@ -228,6 +253,13 @@ export function ThreadView({
228
253
  onAnnotate,
229
254
  onReply,
230
255
  onUpdateAnnotation,
256
+ isAnnotationReadOnly,
257
+ annotationAction,
258
+ requestedBlock,
259
+ onInvoke,
260
+ isPromptBlock,
261
+ promptFocusRequest,
262
+ promptRestoreRequest,
231
263
  dragViewport: () => scrollBoxDragViewport(scrollRef.current),
232
264
  resolveAuthorLabel,
233
265
  onNavCommand,
@@ -316,7 +348,7 @@ export function ThreadView({
316
348
  </span>
317
349
  );
318
350
 
319
- const lineRowFor = (context: LineContext): React.ReactNode => {
351
+ const lineRowFor = (context: LineContext, hasComposer = false): React.ReactNode => {
320
352
  const {
321
353
  blockIndex,
322
354
  roleRuns,
@@ -329,6 +361,9 @@ export function ThreadView({
329
361
  syntaxSpans,
330
362
  } = context;
331
363
 
364
+ // The prompt composer occupies its blank line instead of adding a row below it.
365
+ if (isPromptBlock?.(blockIndex) && hasComposer) return null;
366
+
332
367
  return (
333
368
  <box key={`line-${lineIndex}`} style={{ flexDirection: "row" }}>
334
369
  <text selectable={false}>
@@ -366,6 +401,14 @@ export function ThreadView({
366
401
  };
367
402
 
368
403
  const blockNodeFor = (blockIndex: number): React.ReactNode => {
404
+ const custom = renderBlock?.(blockIndex);
405
+
406
+ if (custom !== undefined)
407
+ return (
408
+ <box key={`custom-${blockIndex}`} ref={virtual.measureRef(blockIndex)}>
409
+ {custom}
410
+ </box>
411
+ );
369
412
  const block = display[blockIndex]!;
370
413
  const { baseFg, baseAttributes, marker } = blockStyle(block, tokens);
371
414
  // list items of one list stay tight; every other block sits a blank row below its neighbour
@@ -422,22 +465,25 @@ export function ThreadView({
422
465
  const line = lines[lineIndex]!;
423
466
  const isLastLine = lineIndex === lines.length - 1;
424
467
 
468
+ const cards = surface.cardsAfterLine(blockIndex, line, isLastLine);
469
+
425
470
  lineRows.push(
426
- lineRowFor({
427
- blockIndex,
428
- roleRuns,
429
- line,
430
- lineIndex,
431
- ranges,
432
- marker,
433
- baseFg,
434
- baseAttributes,
435
- syntaxSpans,
436
- }),
471
+ lineRowFor(
472
+ {
473
+ blockIndex,
474
+ roleRuns,
475
+ line,
476
+ lineIndex,
477
+ ranges,
478
+ marker,
479
+ baseFg,
480
+ baseAttributes,
481
+ syntaxSpans,
482
+ },
483
+ cards.length > 0,
484
+ ),
437
485
  );
438
486
  if (isLastLine) lineRows.push(headingRule(block));
439
- const cards = surface.cardsAfterLine(blockIndex, line, isLastLine);
440
-
441
487
  lineRows.push(...cards);
442
488
  if (cards.length > 0 && !isLastLine) {
443
489
  lineRows.push(<box key={`gap-${lineIndex}`} style={{ height: 1 }} />);
@@ -38,6 +38,7 @@ export function runsFor(text: string, ranges: MarkRange[]): Run[] {
38
38
 
39
39
  if (end <= start) continue;
40
40
  const covering = ranges.filter((range) => range.start <= start && end <= range.end);
41
+
41
42
  // the caret cell shows through a mark, so the head of a selection is visible
42
43
  runs.push({
43
44
  text: text.slice(start, end),
@@ -25,6 +25,7 @@ export interface MarkdownHighlightRange {
25
25
  /** The absolute source offset where each 0-based line begins. */
26
26
  function lineStartOffsets(source: string): number[] {
27
27
  const offsets = [0];
28
+
28
29
  for (let index = 0; index < source.length; index++) {
29
30
  if (source[index] === "\n") offsets.push(index + 1);
30
31
  }
@@ -39,6 +40,7 @@ const INLINE_TOKEN = /(`+)(?:.+?)\1|\[[^\]]+\]\([^)]+\)/g;
39
40
  function scanInlineTokens(text: string, base: number, into: MarkdownHighlightRange[]): void {
40
41
  INLINE_TOKEN.lastIndex = 0;
41
42
  let match: RegExpExecArray | null;
43
+
42
44
  while ((match = INLINE_TOKEN.exec(text)) !== null) {
43
45
  const token = match[0];
44
46
  const start = base + match.index;
@@ -64,6 +64,7 @@ test("phaseMarksToSpans builds a scope root with one child span per phase under
64
64
 
65
65
  expect(spans).toHaveLength(3);
66
66
  const [root, first, second] = spans;
67
+
67
68
  expect(root!.name).toBe("cueloop.startup");
68
69
  expect(root!.parentSpanId).toBeUndefined();
69
70
  expect(first!.name).toBe("cueloop.startup.renderer");
@@ -82,6 +83,7 @@ test("postOtlpTrace sends no request when the endpoint is unset", async () => {
82
83
  return new Response("{}");
83
84
  },
84
85
  });
86
+
85
87
  try {
86
88
  await postOtlpTrace("cueloop-client", [span]);
87
89
  } finally {
@@ -101,6 +103,7 @@ test("postOtlpTrace POSTs OTLP JSON to /v1/traces when the endpoint is set", asy
101
103
  return new Response("{}");
102
104
  },
103
105
  });
106
+
104
107
  // a trailing slash must not double up into "//v1/traces"
105
108
  process.env.OTEL_EXPORTER_OTLP_ENDPOINT = `http://127.0.0.1:${server.port}/`;
106
109
  try {
@@ -28,6 +28,7 @@ export interface TraceSpan {
28
28
 
29
29
  function randomHex(bytes: number): string {
30
30
  const buffer = new Uint8Array(bytes);
31
+
31
32
  crypto.getRandomValues(buffer);
32
33
 
33
34
  return Array.from(buffer, (byte) => byte.toString(16).padStart(2, "0")).join("");
@@ -97,6 +98,7 @@ export function phaseMarksToSpans(scope: string, marks: readonly PhaseMark[]): T
97
98
  let cursorMs = startMs;
98
99
  const children = marks.map((mark) => {
99
100
  const phaseStartMs = cursorMs;
101
+
100
102
  cursorMs += mark.elapsedMs;
101
103
 
102
104
  return {
@@ -118,10 +120,12 @@ export async function postOtlpTrace(
118
120
  spans: readonly TraceSpan[],
119
121
  ): Promise<void> {
120
122
  const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
123
+
121
124
  if (endpoint === undefined || spans.length === 0) return;
122
125
 
123
126
  // strip a trailing slash so an endpoint like "http://host:4318/" does not become "//v1/traces"
124
127
  const base = endpoint.replace(/\/+$/, "");
128
+
125
129
  await fetch(`${base}/v1/traces`, {
126
130
  method: "POST",
127
131
  headers: { "content-type": "application/json" },
@@ -10,6 +10,7 @@ const PERF_SERVICE_NAME = "cueloop-client";
10
10
 
11
11
  export function reportPerfMarks(scope: string): void {
12
12
  const marks = takePerfMarks();
13
+
13
14
  if (marks.length === 0) return;
14
15
 
15
16
  if (process.env.CUELOOP_PERF === "1") process.stderr.write(formatPerfBlock(scope, marks));
@@ -18,6 +18,7 @@ function perfInstrumentationEnabled(): boolean {
18
18
  export function perfMark(label: string): void {
19
19
  if (!perfInstrumentationEnabled()) return;
20
20
  const now = performance.now();
21
+
21
22
  phaseMarks.push({ label, elapsedMs: now - (lastMarkAt ?? 0) });
22
23
  lastMarkAt = now;
23
24
  }
@@ -99,12 +99,15 @@ describe("editing an expanded action", () => {
99
99
  />,
100
100
  { width: 60, height: 8 },
101
101
  );
102
+
102
103
  await settle(setup);
103
104
 
104
105
  // clicking the title opens the title field; clicking the description opens the metadata field
105
106
  const title = locateText(setup, "Ship it");
107
+
106
108
  await setup.mockMouse.click(title.column, title.row);
107
109
  const description = locateText(setup, "be terse");
110
+
108
111
  await setup.mockMouse.click(description.column, description.row);
109
112
 
110
113
  expect(toggles).toEqual([
@@ -659,3 +659,18 @@ describe("the /lgtm default quick action", () => {
659
659
  expect(quickActionBody(lgtm!)).toBe("LGTM\n\nThis looks good to me.");
660
660
  });
661
661
  });
662
+
663
+ test("Thread agents default off and require a boolean experimental TOML flag", () => {
664
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-agent-config-"));
665
+ const path = join(dir, "config.toml");
666
+
667
+ try {
668
+ expect(loadConfig({ userConfigPath: path }).experimental.threadAgent).toBe(false);
669
+ writeFileSync(path, "[experimental]\nthread_agent = true\n");
670
+ expect(loadConfig({ userConfigPath: path }).experimental.threadAgent).toBe(true);
671
+ writeFileSync(path, '[experimental]\nthread_agent = "true"\n');
672
+ expect(loadConfig({ userConfigPath: path }).experimental.threadAgent).toBe(false);
673
+ } finally {
674
+ rmSync(dir, { recursive: true, force: true });
675
+ }
676
+ });
@@ -144,6 +144,8 @@ export interface CueloopConfig {
144
144
  export interface ExperimentalConfig {
145
145
  /** Render a prototype as a pixel mockup (kitty graphics) instead of the default markdown design doc. */
146
146
  prototypePixels: boolean;
147
+ /** Allow the local session owner to invoke a harness inside the Thread. */
148
+ threadAgent: boolean;
147
149
  }
148
150
 
149
151
  /** Where a display name came from: typed by the user, or verified from a GitHub login. */
@@ -251,6 +253,7 @@ const ObsidianSchema = v.object({
251
253
  const IntegrationsSchema = v.object({ obsidian: v.optional(ObsidianSchema) });
252
254
  const ExperimentalSchema = v.object({
253
255
  prototype_pixels: v.fallback(v.optional(v.boolean()), undefined),
256
+ thread_agent: v.fallback(v.optional(v.boolean()), undefined),
254
257
  });
255
258
  const ReviewSchema = v.object({
256
259
  skill: v.fallback(v.optional(v.string()), undefined),
@@ -369,9 +372,7 @@ function layer(
369
372
  if (integrations.success && integrations.output.obsidian) {
370
373
  mergeObsidian(out.integrations.obsidian, integrations.output.obsidian);
371
374
  }
372
- if (experimental.success && experimental.output.prototype_pixels !== undefined) {
373
- out.experimental.prototypePixels = experimental.output.prototype_pixels;
374
- }
375
+ if (experimental.success) applyExperimental(out.experimental, experimental.output);
375
376
  if (review.success) applyReview(out.review, review.output);
376
377
 
377
378
  return out;
@@ -397,7 +398,7 @@ export function loadConfig(
397
398
  skillsPath: join(homedir(), ".agents", "skills"),
398
399
  review: { skill: "code-review", workspace: "worktree" },
399
400
  integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
400
- experimental: { prototypePixels: false },
401
+ experimental: { prototypePixels: false, threadAgent: false },
401
402
  };
402
403
  // Theme name and per-token overrides are separate concerns, composed once
403
404
  // after all layers: the last file to set [ui] theme wins, and every [theme]
@@ -629,3 +630,12 @@ export function persistAuthorName(id: string, name: string, userConfigPath?: str
629
630
  mkdirSync(dirname(path), { recursive: true });
630
631
  writeFileSync(path, text);
631
632
  }
633
+
634
+ /** Only explicitly configured experimental switches override the preceding layer. */
635
+ function applyExperimental(
636
+ config: ExperimentalConfig,
637
+ raw: v.InferOutput<typeof ExperimentalSchema>,
638
+ ): void {
639
+ if (raw.prototype_pixels !== undefined) config.prototypePixels = raw.prototype_pixels;
640
+ if (raw.thread_agent !== undefined) config.threadAgent = raw.thread_agent;
641
+ }
@@ -2282,3 +2282,77 @@ exports[`stories catalog Collaborator Join/JoinSplash renders, snapshots, and ca
2282
2282
 
2283
2283
  "
2284
2284
  `;
2285
+
2286
+ exports["stories catalog Surfaces/AgentThreadPane/CompletedAnswer renders, snapshots, and carries its colors: Surfaces/AgentThreadPane/CompletedAnswer 1"] = "\n\" Artifact Agent fx ready Tools (1) \nWorkspace: /project \n \n You \n ────────────────────────────────────────────────────────────────────── \n \n Why does the retry survive cancellation? \n \n 1 tool call · 1 reads · 0 commands \n \n fx \n ────────────────────────────────────────────────────────────────────── \n \n The timer survives cancellation. Clear it when the request ends. \n \n \n \n \n \n \n \n \n \n \n────────────────────────────────────────────────────────────────────────────────\n Ask fx… Ctrl+Enter sends · Esc reads · i composes \n \n Send message (0) \n\"\n";
2287
+
2288
+ exports["stories catalog Surfaces/AgentThreadPane/WaitingPermission renders, snapshots, and carries its colors: Surfaces/AgentThreadPane/WaitingPermission 1"] = "\n\" Artifact Agent fx working Tools (1) Stop \nWorkspace: /project \n \n You \n ────────────────────────────────────────────────────────────────────── \n \n Why does the retry survive cancellation? \n \n 1 tool call · 1 reads · 0 commands \n \n fx \n ────────────────────────────────────────────────────────────────────── \n \n The timer survives cancellation. Clear it when the request ends. \n \n \n \n \n \n \n┌──────────────────────────────────────────────────────────────────────────────┐\n│ Update retry.ts │\n│ Allow once Reject once │\n└──────────────────────────────────────────────────────────────────────────────┘\n────────────────────────────────────────────────────────────────────────────────\n Wait for fx, or stop the tur \n \n Send message (0) \n\"\n";
2289
+
2290
+ exports[`stories catalog Chrome/AgentConfigControls/Choices renders, snapshots, and carries its colors: Chrome/AgentConfigControls/Choices 1`] = `
2291
+ "Test model Medium
2292
+
2293
+ "
2294
+ `;
2295
+
2296
+ exports[`stories catalog Surfaces/AgentThreadPane/CompletedAnswer renders, snapshots, and carries its colors: Surfaces/AgentThreadPane/CompletedAnswer 1`] = `
2297
+ "
2298
+ Keep the retry bounded.
2299
+
2300
+ Why does the retry survive cancellation?
2301
+
2302
+ The timer survives cancellation. Clear it when the request ends.
2303
+
2304
+
2305
+
2306
+
2307
+
2308
+
2309
+
2310
+
2311
+
2312
+
2313
+
2314
+
2315
+
2316
+
2317
+
2318
+
2319
+
2320
+
2321
+
2322
+
2323
+
2324
+ type to comment · esc for nav mode
2325
+ "
2326
+ `;
2327
+
2328
+ exports[`stories catalog Surfaces/AgentThreadPane/WaitingPermission renders, snapshots, and carries its colors: Surfaces/AgentThreadPane/WaitingPermission 1`] = `
2329
+ "
2330
+ Keep the retry bounded.
2331
+
2332
+ Why does the retry survive cancellation?
2333
+
2334
+ The timer survives cancellation. Clear it when the request ends.
2335
+ Thinking…
2336
+ Update retry.ts
2337
+ Allow once Reject once
2338
+
2339
+
2340
+
2341
+
2342
+
2343
+
2344
+
2345
+
2346
+
2347
+
2348
+
2349
+
2350
+
2351
+
2352
+
2353
+
2354
+
2355
+
2356
+ type to comment · esc for nav mode
2357
+ "
2358
+ `;
@@ -59,6 +59,7 @@ function StoriesApp({ stories, onExit }: StoriesAppProps): React.ReactNode {
59
59
  const toggleFolder = (id: string): void => {
60
60
  setExpandedIds((current) => {
61
61
  const next = new Set(current);
62
+
62
63
  if (next.has(id)) next.delete(id);
63
64
  else next.add(id);
64
65
 
@@ -73,6 +74,7 @@ function StoriesApp({ stories, onExit }: StoriesAppProps): React.ReactNode {
73
74
  const collapseFolder = (id: string): void => {
74
75
  setExpandedIds((current) => {
75
76
  const next = new Set(current);
77
+
76
78
  next.delete(id);
77
79
 
78
80
  return next;
@@ -83,6 +85,7 @@ function StoriesApp({ stories, onExit }: StoriesAppProps): React.ReactNode {
83
85
  const index = rows.findIndex((row) => row.id === selectedId);
84
86
  const nextIndex = Math.max(0, Math.min(rows.length - 1, index + delta));
85
87
  const nextRow = rows[nextIndex];
88
+
86
89
  if (nextRow !== undefined) setSelectedId(nextRow.id);
87
90
  };
88
91
 
@@ -97,6 +97,7 @@ function hoistSingleStoryFolders(nodes: TreeNode[]): TreeNode[] {
97
97
  if (node.children === undefined) return node;
98
98
  const children = hoistSingleStoryFolders(node.children);
99
99
  const onlyChild = children.length === 1 ? children[0] : undefined;
100
+
100
101
  if (
101
102
  onlyChild !== undefined &&
102
103
  onlyChild.children === undefined &&
@@ -126,6 +127,7 @@ export function buildStoryTree(stories: LoadedStory[]): TreeNode[] {
126
127
  for (const segment of [section, ...moduleTitle.split("/")]) {
127
128
  path = path === "" ? segment : `${path}/${segment}`;
128
129
  let folder = foldersByPath.get(path);
130
+
129
131
  if (folder === undefined) {
130
132
  folder = { id: path, label: segment, children: [] };
131
133
  foldersByPath.set(path, folder);
@@ -228,6 +228,7 @@ class Pty implements IPty {
228
228
  this.exitEvent.fire({ exitCode });
229
229
  } else {
230
230
  const delay = Math.min(READ_IDLE_MAX_MS, READ_IDLE_MS * 2 ** Math.min(idlePolls, 5));
231
+
231
232
  idlePolls += 1;
232
233
  await new Promise((resolve) => setTimeout(resolve, delay));
233
234
  }
@@ -0,0 +1,32 @@
1
+ import { expect, test } from "bun:test";
2
+ import { agentContinuation, type AgentContinuation } from "./agent-continuation";
3
+ import type { ThreadAgentState } from "@cueloop/schema";
4
+
5
+ const state: ThreadAgentState = {
6
+ threadId: "thread",
7
+ phase: { kind: "idle" },
8
+ messages: [],
9
+ comments: [],
10
+ tools: [],
11
+ };
12
+
13
+ test("only a completed reply in an idle conversation offers continuation", () => {
14
+ expect(agentContinuation(state)).toEqual({ kind: "waiting" });
15
+ const message = {
16
+ id: "answer",
17
+ role: "agent" as const,
18
+ text: "Done",
19
+ complete: true,
20
+ revision: 1,
21
+ };
22
+ const completed: AgentContinuation = agentContinuation({ ...state, messages: [message] });
23
+
24
+ expect(completed.kind).toBe("ready");
25
+ if (completed.kind === "ready") expect(completed.replyId).toBe("answer");
26
+ expect(agentContinuation({ ...state, phase: { kind: "running" }, messages: [message] })).toEqual({
27
+ kind: "waiting",
28
+ });
29
+ expect(agentContinuation({ ...state, messages: [{ ...message, complete: false }] })).toEqual({
30
+ kind: "waiting",
31
+ });
32
+ });
@@ -0,0 +1,13 @@
1
+ import type { ThreadAgentState } from "@cueloop/schema";
2
+
3
+ /** Chat continuation names a completed reply only when the harness has returned to idle. */
4
+ export type AgentContinuation = { kind: "waiting" } | { kind: "ready"; replyId: string };
5
+
6
+ /** Streaming replies cannot request the next prompt's focus. */
7
+ export function agentContinuation(state: ThreadAgentState): AgentContinuation {
8
+ const reply = state.messages.findLast((message) => message.role === "agent");
9
+
10
+ if (state.phase.kind !== "idle" || !reply?.complete) return { kind: "waiting" };
11
+
12
+ return { kind: "ready", replyId: reply.id };
13
+ }