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

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 (32) hide show
  1. package/package.json +5 -5
  2. package/src/annotations/components/AnnotationCards.test.tsx +42 -0
  3. package/src/annotations/components/AnnotationCards.tsx +29 -6
  4. package/src/annotations/use-annotation-surface.tsx +245 -30
  5. package/src/app/App.thread-agent.test.ts +12 -0
  6. package/src/app/App.tsx +217 -97
  7. package/src/app/app-screens.tsx +2 -0
  8. package/src/app/components/AppShell.zoom.test.tsx +4 -3
  9. package/src/app/intent-dispatch.test.ts +1 -0
  10. package/src/diff/components/AnnotatableFileView.tsx +6 -0
  11. package/src/diff/components/DiffContentView.tsx +9 -0
  12. package/src/markdown/components/ThreadView.tsx +60 -16
  13. package/src/settings/config.test.ts +15 -0
  14. package/src/settings/config.ts +14 -4
  15. package/src/stories/__snapshots__/stories.test.tsx.snap +74 -0
  16. package/src/thread/agent-continuation.test.ts +32 -0
  17. package/src/thread/agent-continuation.ts +13 -0
  18. package/src/thread/agent-transcript.test.ts +161 -0
  19. package/src/thread/agent-transcript.ts +332 -0
  20. package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
  21. package/src/thread/components/AgentConfigControls.test.tsx +110 -0
  22. package/src/thread/components/AgentConfigControls.tsx +114 -0
  23. package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
  24. package/src/thread/components/AgentThreadPane.test.tsx +780 -0
  25. package/src/thread/components/AgentThreadPane.tsx +356 -0
  26. package/src/thread/components/ThreadFooter.tsx +3 -0
  27. package/src/thread/components/ThreadTree.test.tsx +41 -0
  28. package/src/thread/components/ThreadTree.tsx +25 -5
  29. package/src/thread/thread-controller.ts +34 -0
  30. package/src/thread/use-thread-agent.test.tsx +65 -0
  31. package/src/thread/use-thread-agent.ts +101 -0
  32. package/src/workbench/components/GridTabContent.tsx +6 -0
package/src/app/App.tsx CHANGED
@@ -35,7 +35,11 @@ import {
35
35
  type ThemeName,
36
36
  } from "../appearance/theme-presets";
37
37
  import type { Theme } from "../appearance/theme";
38
- import { createReviewController, type ShareTransport } from "../thread/thread-controller";
38
+ import {
39
+ createReviewController,
40
+ type ShareTransport,
41
+ type ReviewController,
42
+ } from "../thread/thread-controller";
39
43
  import type { ThreadClient } from "@cueloop/daemon/client";
40
44
  import { createIntentDispatch, type Mode, type RailTab } from "./intent-dispatch";
41
45
  import { reduceKey, type KeyState } from "../keyboard/keymap";
@@ -62,6 +66,7 @@ import { useThreadBodyEditing } from "../markdown/use-thread-body-editing";
62
66
  import { useRememberLayout } from "../workbench/use-remember-layout";
63
67
  import type { LaunchLayout } from "./launch-layout";
64
68
  import { ThreadFooter, THREAD_FOOTER_HEIGHT } from "../thread/components/ThreadFooter";
69
+ import { AgentThreadPrototype, agentOwnsKeyboard } from "../thread/components/AgentThreadPane";
65
70
  import { ConfirmCard } from "../ui/components/ConfirmCard";
66
71
  import { THREAD_VIEW_CHEATSHEET, ThreadView } from "../markdown/components/ThreadView";
67
72
  import {
@@ -697,11 +702,13 @@ export function App({
697
702
  );
698
703
  // opt-in: render a prototype as a kitty pixel mockup instead of the markdown doc
699
704
  const [prototypePixels, setPrototypePixels] = useState(false);
705
+ const [threadAgentEnabled, setThreadAgentEnabled] = useState(false);
700
706
 
701
707
  useEffect(() => {
702
708
  const config = loadConfig({ repoRoot: session?.workspace.repoRoot });
703
709
 
704
710
  setPrototypePixels(config.experimental.prototypePixels);
711
+ setThreadAgentEnabled(config.experimental.threadAgent);
705
712
  keysRef.current = config.keys;
706
713
  keyBindings.setKeys(config.keys);
707
714
  setTheme(composeTheme(config.ui.theme, config.themeOverrides, appearance));
@@ -849,6 +856,13 @@ export function App({
849
856
  // diff sheet: both drive the shared annotation surface; only the pixel prototype keeps the keymap
850
857
  const threadViewActive = session !== null && !isPixelPrototype;
851
858
  const [threadComposing, setThreadComposing] = useState(false);
859
+ const [agentActive, setAgentActive] = useState(false);
860
+ const [agentControls, setAgentControls] = useState<React.ReactNode>(null);
861
+ const agentActions = useRef<(id: string) => { label: string; run: () => void } | undefined>(
862
+ () => undefined,
863
+ );
864
+ const agentInvoke = useRef<(() => void) | undefined>(undefined);
865
+ const [agentState, setAgentState] = useState<import("@cueloop/schema").ThreadAgentState>();
852
866
  const [prototypeComposing, setPrototypeComposing] = useState(false);
853
867
  const [welcomeComposing, setWelcomeComposing] = useState(false);
854
868
  // inline body edit: the markdown editor owns the thread pane and all keys while open
@@ -1020,7 +1034,28 @@ export function App({
1020
1034
  controller.open(id);
1021
1035
  };
1022
1036
 
1037
+ const createEmptyThread = enabledThreadAction(
1038
+ canRunThreadAgent({
1039
+ enabled: threadAgentEnabled,
1040
+ owner: isOwner,
1041
+ editing: false,
1042
+ shared: isSharedThreadConnection(openClient, shareTransport),
1043
+ }),
1044
+ () => {
1045
+ void controller.createEmptyThread();
1046
+ setFocusedPane("thread");
1047
+ },
1048
+ );
1049
+
1023
1050
  const threadSurfaceHandledKey = (key: KeyEvent): boolean => {
1051
+ if (
1052
+ agentOwnsKeyboard(agentActive, focusedPane) &&
1053
+ (key.name === "return" || key.name === "enter") &&
1054
+ key.ctrl &&
1055
+ !key.meta &&
1056
+ !key.super
1057
+ )
1058
+ return true;
1024
1059
  if (!threadViewActive || threadViewSuspended) return false;
1025
1060
  if (!threadComposing) {
1026
1061
  const chord = resolveSessionChord(key, { isOwner, resolved });
@@ -1130,6 +1165,7 @@ export function App({
1130
1165
  rows={grouped.rows}
1131
1166
  inboxCursor={inboxCursor}
1132
1167
  onOpenThread={openThread}
1168
+ onCreateThread={createEmptyThread}
1133
1169
  mode={mode}
1134
1170
  theme={theme}
1135
1171
  controller={controller}
@@ -1225,6 +1261,7 @@ export function App({
1225
1261
 
1226
1262
  const threadFooter = (
1227
1263
  <ThreadFooter
1264
+ controls={threadAgentControls(agentActive, agentControls)}
1228
1265
  repo={projectName(activeSession.workspace)}
1229
1266
  branch={activeSession.workspace.branch}
1230
1267
  onSubmit={onSubmitRequest}
@@ -1265,6 +1302,7 @@ export function App({
1265
1302
  pinnedIds={pinnedIds}
1266
1303
  width={30}
1267
1304
  onSelect={openThread}
1305
+ onCreateThread={createEmptyThread}
1268
1306
  onPin={togglePin}
1269
1307
  onRename={(id, title) =>
1270
1308
  setMode({ type: "renameThread", sessionId: id, text: title })
@@ -1278,106 +1316,138 @@ export function App({
1278
1316
  }
1279
1317
  threadTitle={reviewThreadTitle(activeSession)}
1280
1318
  threadActions={
1281
- showOwnerActions
1282
- ? ownerThreadActions({
1283
- editing: bodyEditing.editing,
1284
- onEdit: onEditRequest,
1285
- onExitEdit: bodyEditing.requestExit,
1286
- onShare: () => dispatch({ type: "share" }),
1287
- theme,
1288
- })
1289
- : showDiffRefresh
1290
- ? refreshDiffAction(() => void controller.refreshDiff(), theme)
1291
- : undefined
1319
+ <>
1320
+ {showOwnerActions
1321
+ ? ownerThreadActions({
1322
+ editing: bodyEditing.editing,
1323
+ onEdit: onEditRequest,
1324
+ onExitEdit: bodyEditing.requestExit,
1325
+ onShare: () => dispatch({ type: "share" }),
1326
+ theme,
1327
+ })
1328
+ : showDiffRefresh
1329
+ ? refreshDiffAction(() => void controller.refreshDiff(), theme)
1330
+ : undefined}
1331
+ </>
1292
1332
  }
1293
1333
  threadPanel={
1294
1334
  <box style={{ flexGrow: 1, flexDirection: "column" }}>
1295
1335
  <box style={{ flexGrow: 1, flexDirection: "row" }}>
1296
- {chooseThreadBody({
1297
- isPixelPrototype,
1298
- isDiff,
1299
- editingBody: bodyEditing.editing,
1300
- prototype: (
1301
- <PrototypePixels
1302
- prototypePath={prototypePath}
1303
- canComment={prototypeCanComment}
1304
- onCommentElement={onCommentPrototype}
1305
- onComposingChange={setPrototypeComposing}
1306
- hidden={chromeHidden}
1307
- />
1308
- ),
1309
- diffPlaceholder: activeSession.artifact.meta.prBrief ? (
1310
- <ThreadView
1311
- session={pullRequestBriefThread(activeSession)}
1312
- display={buildDisplay(activeSession.artifact.meta.prBrief)}
1313
- marks={new Map()}
1314
- quickActions={quickActions}
1315
- suspended={threadViewSuspended}
1316
- resolved
1317
- observer
1318
- onComposingChange={() => {}}
1319
- onObserverBlocked={() => {}}
1320
- onCursorChange={() => {}}
1321
- onAnnotate={() => {}}
1322
- onReply={() => {}}
1323
- onUpdateAnnotation={() => {}}
1324
- resolveAuthorLabel={() => undefined}
1325
- onExit={() => onExit?.(0)}
1326
- />
1327
- ) : (
1328
- <box
1329
- style={{
1330
- flexGrow: 1,
1331
- flexDirection: "column",
1332
- alignItems: "center",
1333
- justifyContent: "center",
1334
- }}
1335
- >
1336
- <text fg={theme.textDim}>Select a thread</text>
1337
- </box>
1338
- ),
1339
- editor: bodyEditing.renderEditor(theme),
1340
- threadView: (
1341
- <ThreadView
1342
- session={activeSession}
1343
- suspended={surfaceSuspended(threadViewSuspended, focusedPane, "thread")}
1344
- editOrphanCount={editOrphanCount}
1345
- onComposingChange={setThreadComposing}
1346
- onNavCommand={runNavCommand}
1347
- resolved={resolved}
1348
- onObserverBlocked={(reason) =>
1349
- controller.setStatus(
1350
- reason === "observer"
1351
- ? "observer - read-only"
1352
- : "review submitted - read-only",
1353
- )
1354
- }
1355
- onCursorChange={setCursor}
1356
- focusedAnnotationId={focusedAnnotationId}
1357
- onFocusAnnotation={setFocusedAnnotationId}
1358
- display={display}
1359
- marks={marks}
1360
- quickActions={quickActions}
1361
- observer={observer}
1362
- onAnnotate={(span, body) =>
1363
- void controller.annotate(
1364
- "comment",
1365
- span.start.blockIndex,
1366
- span.start.char,
1367
- span.end.char,
1368
- body,
1369
- span.end.blockIndex,
1370
- )
1371
- }
1372
- onReply={(rootAnnotationId, body) =>
1373
- void controller.reply(rootAnnotationId, body)
1374
- }
1375
- onUpdateAnnotation={(id, body) => controller.updateAnnotation(id, body)}
1376
- resolveAuthorLabel={resolveAuthorLabel}
1377
- onExit={() => onExit?.(0)}
1378
- />
1379
- ),
1380
- })}
1336
+ <AgentThreadPrototype
1337
+ key={activeSession.id}
1338
+ enabled={canRunThreadAgent({
1339
+ enabled: threadAgentEnabled,
1340
+ owner: isOwner,
1341
+ editing: bodyEditing.editing,
1342
+ shared: isSharedThreadConnection(openClient, shareTransport),
1343
+ })}
1344
+ observer={observer}
1345
+ pixelPrototype={isPixelPrototype}
1346
+ thread={activeSession}
1347
+ home={home}
1348
+ theme={theme}
1349
+ focused={focusedPane === "thread"}
1350
+ suspended={threadViewSuspended}
1351
+ onActiveChange={setAgentActive}
1352
+ onControlsChange={setAgentControls}
1353
+ onStateChange={setAgentState}
1354
+ onActionsChange={(actions) => {
1355
+ agentActions.current = actions;
1356
+ }}
1357
+ onInvokeChange={(invoke) => {
1358
+ agentInvoke.current = invoke;
1359
+ }}
1360
+ onRevealReply={() => setFocusedPane("thread")}
1361
+ flushMutations={() => flushReviewMutations(controller)}
1362
+ onOpenFile={(path) => workbench.openFile(path, "contents", true)}
1363
+ onNextPane={cyclePanes}
1364
+ >
1365
+ {chooseThreadBody({
1366
+ isPixelPrototype,
1367
+ isDiff,
1368
+ editingBody: bodyEditing.editing,
1369
+ prototype: (
1370
+ <PrototypePixels
1371
+ prototypePath={prototypePath}
1372
+ canComment={prototypeCanComment}
1373
+ onCommentElement={onCommentPrototype}
1374
+ onComposingChange={setPrototypeComposing}
1375
+ hidden={chromeHidden}
1376
+ />
1377
+ ),
1378
+ diffPlaceholder: activeSession.artifact.meta.prBrief ? (
1379
+ <ThreadView
1380
+ session={pullRequestBriefThread(activeSession)}
1381
+ display={buildDisplay(activeSession.artifact.meta.prBrief)}
1382
+ marks={new Map()}
1383
+ quickActions={quickActions}
1384
+ suspended={threadViewSuspended}
1385
+ resolved
1386
+ observer
1387
+ onComposingChange={() => {}}
1388
+ onObserverBlocked={() => {}}
1389
+ onCursorChange={() => {}}
1390
+ onAnnotate={() => {}}
1391
+ onReply={() => {}}
1392
+ onUpdateAnnotation={() => {}}
1393
+ resolveAuthorLabel={() => undefined}
1394
+ onExit={() => onExit?.(0)}
1395
+ />
1396
+ ) : (
1397
+ <box
1398
+ style={{
1399
+ flexGrow: 1,
1400
+ flexDirection: "column",
1401
+ alignItems: "center",
1402
+ justifyContent: "center",
1403
+ }}
1404
+ >
1405
+ <text fg={theme.textDim}>Select a thread</text>
1406
+ </box>
1407
+ ),
1408
+ editor: bodyEditing.renderEditor(theme),
1409
+ threadView: (
1410
+ <ThreadView
1411
+ session={activeSession}
1412
+ suspended={surfaceSuspended(threadViewSuspended, focusedPane, "thread")}
1413
+ editOrphanCount={editOrphanCount}
1414
+ onComposingChange={setThreadComposing}
1415
+ onNavCommand={runNavCommand}
1416
+ resolved={resolved}
1417
+ onObserverBlocked={(reason) =>
1418
+ controller.setStatus(
1419
+ reason === "observer"
1420
+ ? "observer - read-only"
1421
+ : "review submitted - read-only",
1422
+ )
1423
+ }
1424
+ onCursorChange={setCursor}
1425
+ focusedAnnotationId={focusedAnnotationId}
1426
+ onFocusAnnotation={setFocusedAnnotationId}
1427
+ display={display}
1428
+ marks={marks}
1429
+ quickActions={quickActions}
1430
+ observer={observer}
1431
+ onAnnotate={(span, body) =>
1432
+ controller.annotate(
1433
+ "comment",
1434
+ span.start.blockIndex,
1435
+ span.start.char,
1436
+ span.end.char,
1437
+ body,
1438
+ span.end.blockIndex,
1439
+ )
1440
+ }
1441
+ onReply={(rootAnnotationId, body) =>
1442
+ controller.reply(rootAnnotationId, body)
1443
+ }
1444
+ onUpdateAnnotation={(id, body) => controller.updateAnnotation(id, body)}
1445
+ resolveAuthorLabel={resolveAuthorLabel}
1446
+ onExit={() => onExit?.(0)}
1447
+ />
1448
+ ),
1449
+ })}
1450
+ </AgentThreadPrototype>
1381
1451
  </box>
1382
1452
  {threadFooter}
1383
1453
  </box>
@@ -1444,6 +1514,15 @@ export function App({
1444
1514
  onReply: (rootAnnotationId, body) =>
1445
1515
  void controller.reply(rootAnnotationId, body),
1446
1516
  onUpdateAnnotation: (id, body) => controller.updateAnnotation(id, body),
1517
+ onInvoke: agentActive
1518
+ ? () => {
1519
+ agentInvoke.current?.();
1520
+ setFocusedPane("thread");
1521
+ }
1522
+ : undefined,
1523
+ annotationAction: (id) => agentActions.current(id),
1524
+ isAnnotationReadOnly: (id) =>
1525
+ isSubmittedComment(agentState, activeSession.id, id),
1447
1526
  resolveAuthorLabel,
1448
1527
  onNavCommand: runNavCommand,
1449
1528
  onExit: () => onExit?.(0),
@@ -1548,3 +1627,44 @@ export function App({
1548
1627
  </SlashSkillsContext.Provider>
1549
1628
  );
1550
1629
  }
1630
+
1631
+ function enabledThreadAction(enabled: boolean, action: () => void): (() => void) | undefined {
1632
+ return enabled ? action : undefined;
1633
+ }
1634
+
1635
+ /** Experimental agent UI is available only in a local owner's review. */
1636
+ export function canRunThreadAgent(options: {
1637
+ enabled: boolean;
1638
+ owner: boolean;
1639
+ editing: boolean;
1640
+ shared: boolean;
1641
+ }): boolean {
1642
+ return options.enabled && options.owner && !options.editing && !options.shared;
1643
+ }
1644
+
1645
+ function isSubmittedComment(
1646
+ state: import("@cueloop/schema").ThreadAgentState | undefined,
1647
+ threadId: string,
1648
+ commentId: string,
1649
+ ): boolean {
1650
+ return (
1651
+ state?.threadId === threadId &&
1652
+ Boolean(state.submissions?.some((entry) => entry.commentId === commentId))
1653
+ );
1654
+ }
1655
+
1656
+ function flushReviewMutations(controller: ReviewController): Promise<void> {
1657
+ return controller.flushMutations?.() ?? Promise.resolve();
1658
+ }
1659
+
1660
+ function threadAgentControls(active: boolean, controls: React.ReactNode): React.ReactNode {
1661
+ return active ? controls : undefined;
1662
+ }
1663
+
1664
+ /** Injected shared transports never own a local harness connection. */
1665
+ function isSharedThreadConnection(
1666
+ openClient: AppProps["openClient"],
1667
+ shareTransport: ShareTransport | undefined,
1668
+ ): boolean {
1669
+ return Boolean(openClient || shareTransport);
1670
+ }
@@ -160,6 +160,7 @@ export function NoThreadShell(props: {
160
160
  inboxCursor: number;
161
161
  /** Open a sidebar thread and move the cursor onto it, so the row shows its selected backdrop at once. */
162
162
  onOpenThread: (id: string) => void;
163
+ onCreateThread?: () => void;
163
164
  mode: Mode;
164
165
  theme: Theme;
165
166
  controller: ReviewController;
@@ -291,6 +292,7 @@ export function NoThreadShell(props: {
291
292
  pinnedIds={pinnedIds}
292
293
  width={30}
293
294
  onSelect={onOpenThread}
295
+ onCreateThread={props.onCreateThread}
294
296
  onRequestDelete={(id, title) =>
295
297
  setMode({ type: "confirmDelete", sessionId: id, title })
296
298
  }
@@ -13,7 +13,7 @@ import { AppShell } from "./AppShell";
13
13
  import { EditorGrid } from "../../workbench/components/EditorGrid";
14
14
  import { changesTab, makeGroup, splitGroup } from "../../workbench/components/editor-grid";
15
15
  import { NERD } from "../../ui/components/primitives/icons";
16
- import { allowEventLoopUpdates } from "../../testing/test-support";
16
+ import { allowEventLoopUpdates, settle } from "../../testing/test-support";
17
17
 
18
18
  const grid = makeGroup([changesTab()]);
19
19
 
@@ -59,8 +59,9 @@ async function zoomIconColumn(
59
59
  { width, height: 8 },
60
60
  );
61
61
 
62
- allowEventLoopUpdates();
63
- await setup.waitForVisualIdle();
62
+ // Split widths commit through React after the frame measurement. Renderer idle alone
63
+ // can still expose the initial fractional split, before its whole-cell widths land.
64
+ await settle(setup);
64
65
  const column = setup.captureCharFrame().split("\n")[0]!.indexOf(NERD.zoom);
65
66
 
66
67
  setup.renderer.destroy();
@@ -62,6 +62,7 @@ const EMPTY_SNAPSHOT: ControllerSnapshot = {
62
62
  function baseController(): ReviewController {
63
63
  return {
64
64
  readOnly: false,
65
+ createEmptyThread: mock(async () => {}),
65
66
  subscribe: mock(() => () => {}),
66
67
  getSnapshot: mock(() => EMPTY_SNAPSHOT),
67
68
  connect: mock(),
@@ -34,6 +34,9 @@ export interface AnnotatableFileViewProps {
34
34
  onAddComment: (anchor: Anchor, body: string) => void;
35
35
  onReply: (rootAnnotationId: string, body: string) => void;
36
36
  onUpdateAnnotation: (id: string, body: string) => void;
37
+ onInvoke?: () => void;
38
+ annotationAction?: (id: string) => { label: string; run: () => void } | undefined;
39
+ isAnnotationReadOnly?: (id: string) => boolean;
37
40
  resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
38
41
  onExit: () => void;
39
42
  theme?: Theme;
@@ -126,6 +129,9 @@ export function AnnotatableFileView(props: AnnotatableFileViewProps): React.Reac
126
129
  }
127
130
  onReply={props.onReply}
128
131
  onUpdateAnnotation={props.onUpdateAnnotation}
132
+ onInvoke={props.onInvoke}
133
+ isAnnotationReadOnly={props.isAnnotationReadOnly}
134
+ annotationAction={props.annotationAction}
129
135
  resolveAuthorLabel={props.resolveAuthorLabel}
130
136
  onExit={props.onExit}
131
137
  theme={props.theme}
@@ -98,6 +98,9 @@ export interface DiffContentViewProps {
98
98
  onAnnotate: (span: TextSpan, body: string) => void;
99
99
  onReply: (rootAnnotationId: string, body: string) => void;
100
100
  onUpdateAnnotation: (id: string, body: string) => void;
101
+ onInvoke?: () => void;
102
+ annotationAction?: (id: string) => { label: string; run: () => void } | undefined;
103
+ isAnnotationReadOnly?: (id: string) => boolean;
101
104
  /** The author's display name for a comment's hover tooltip. */
102
105
  resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
103
106
  onNavCommand?: (key: KeyEvent, selection: TextSpan | null) => boolean;
@@ -542,6 +545,9 @@ export function DiffContentView({
542
545
  onAnnotate,
543
546
  onReply,
544
547
  onUpdateAnnotation,
548
+ onInvoke,
549
+ isAnnotationReadOnly,
550
+ annotationAction,
545
551
  resolveAuthorLabel,
546
552
  onNavCommand,
547
553
  onExit,
@@ -610,6 +616,9 @@ export function DiffContentView({
610
616
  onAnnotate,
611
617
  onReply,
612
618
  onUpdateAnnotation,
619
+ onInvoke,
620
+ isAnnotationReadOnly,
621
+ annotationAction,
613
622
  dragViewport: () => scrollBoxDragViewport(scrollRef.current),
614
623
  resolveAuthorLabel: (annotation) => {
615
624
  const label = resolveAuthorLabel?.(annotation);
@@ -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,13 @@ export function ThreadView({
366
401
  };
367
402
 
368
403
  const blockNodeFor = (blockIndex: number): React.ReactNode => {
404
+ const custom = renderBlock?.(blockIndex);
405
+ if (custom !== undefined)
406
+ return (
407
+ <box key={`custom-${blockIndex}`} ref={virtual.measureRef(blockIndex)}>
408
+ {custom}
409
+ </box>
410
+ );
369
411
  const block = display[blockIndex]!;
370
412
  const { baseFg, baseAttributes, marker } = blockStyle(block, tokens);
371
413
  // list items of one list stay tight; every other block sits a blank row below its neighbour
@@ -422,22 +464,24 @@ export function ThreadView({
422
464
  const line = lines[lineIndex]!;
423
465
  const isLastLine = lineIndex === lines.length - 1;
424
466
 
467
+ const cards = surface.cardsAfterLine(blockIndex, line, isLastLine);
425
468
  lineRows.push(
426
- lineRowFor({
427
- blockIndex,
428
- roleRuns,
429
- line,
430
- lineIndex,
431
- ranges,
432
- marker,
433
- baseFg,
434
- baseAttributes,
435
- syntaxSpans,
436
- }),
469
+ lineRowFor(
470
+ {
471
+ blockIndex,
472
+ roleRuns,
473
+ line,
474
+ lineIndex,
475
+ ranges,
476
+ marker,
477
+ baseFg,
478
+ baseAttributes,
479
+ syntaxSpans,
480
+ },
481
+ cards.length > 0,
482
+ ),
437
483
  );
438
484
  if (isLastLine) lineRows.push(headingRule(block));
439
- const cards = surface.cardsAfterLine(blockIndex, line, isLastLine);
440
-
441
485
  lineRows.push(...cards);
442
486
  if (cards.length > 0 && !isLastLine) {
443
487
  lineRows.push(<box key={`gap-${lineIndex}`} style={{ height: 1 }} />);
@@ -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
+ });