@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
@@ -193,6 +193,7 @@ describe("diff review", () => {
193
193
  test("pasting an image into a diff comment drops in an [Image #n] placeholder", async () => {
194
194
  // Arrange - mark the added line and open the inline comment composer
195
195
  const setup = await renderApp();
196
+
196
197
  await dragText(setup, "new Map()", "new Map()", "new Map()".length);
197
198
  await typeText(setup, "see ");
198
199
 
@@ -34,6 +34,7 @@ beforeEach(() => {
34
34
  meta: { title: "Migration Plan", planPath: "plan.md" },
35
35
  },
36
36
  });
37
+
37
38
  sessionId = session.id;
38
39
  });
39
40
  afterEach(() => {
@@ -48,6 +48,7 @@ function accentTitle(setup: Awaited<ReturnType<typeof renderReadyApp>>): string
48
48
  for (const line of setup.captureSpans().lines) {
49
49
  for (const span of line.spans) {
50
50
  const rgb = span.fg?.toInts();
51
+
51
52
  if (
52
53
  rgb &&
53
54
  rgb[0] === ACCENT_DARK[0] &&
@@ -55,6 +56,7 @@ function accentTitle(setup: Awaited<ReturnType<typeof renderReadyApp>>): string
55
56
  rgb[2] === ACCENT_DARK[2]
56
57
  ) {
57
58
  const text = span.text.trim();
59
+
58
60
  if (text.includes("review")) return text;
59
61
  }
60
62
  }
@@ -69,6 +71,7 @@ test("ctrl+q quits even when a menu holds the keyboard", async () => {
69
71
  width: 120,
70
72
  height: 32,
71
73
  });
74
+
72
75
  await waitForText(setup, "review");
73
76
 
74
77
  // open the settings dialog from the top-left gear - it takes the keyboard
@@ -83,10 +86,12 @@ test("ctrl+q quits even when a menu holds the keyboard", async () => {
83
86
 
84
87
  test("clicking a sidebar thread gives it the selected accent at once", async () => {
85
88
  const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
89
+
86
90
  await waitForText(setup, "Second review");
87
91
 
88
92
  // click whichever thread is not already the selected (accent) one
89
93
  const target = accentTitle(setup)?.includes("Second") ? "First review" : "Second review";
94
+
90
95
  await clickText(setup, target);
91
96
 
92
97
  expect(accentTitle(setup)).toContain(target.split(" ")[0]!);
@@ -67,6 +67,7 @@ describe("share dialog", () => {
67
67
  <App home={home} sessionId={session.id} shareTransport={shareTransport} />,
68
68
  { width: 120, height: 32 },
69
69
  );
70
+
70
71
  await waitForText(setup, "cueloop");
71
72
 
72
73
  // Act
@@ -84,6 +85,7 @@ describe("share dialog", () => {
84
85
  <App home={home} sessionId={session.id} shareTransport={shareTransport} />,
85
86
  { width: 120, height: 32 },
86
87
  );
88
+
87
89
  await waitForText(setup, "cueloop");
88
90
 
89
91
  // Act: open the dialog, step into the body, activate "+ new link"
@@ -116,6 +118,7 @@ describe("share dialog", () => {
116
118
  <App home={home} sessionId={session.id} shareTransport={shareTransport} />,
117
119
  { width: 120, height: 32 },
118
120
  );
121
+
119
122
  await waitForText(setup, "cueloop");
120
123
 
121
124
  // Act: open the wizard, turn auth on but add no handles, then try to create
@@ -140,6 +143,7 @@ describe("share dialog", () => {
140
143
  <App home={home} sessionId={session.id} shareTransport={shareTransport} />,
141
144
  { width: 120, height: 32 },
142
145
  );
146
+
143
147
  await waitForText(setup, "cueloop");
144
148
 
145
149
  // Act + Assert: right arrow crosses into the body (its hint appears)
@@ -159,6 +163,7 @@ describe("share dialog", () => {
159
163
  <App home={home} sessionId={session.id} shareTransport={shareTransport} />,
160
164
  { width: 120, height: 32 },
161
165
  );
166
+
162
167
  await waitForText(setup, "cueloop");
163
168
 
164
169
  // Act
@@ -111,6 +111,7 @@ describe("send message confirm", () => {
111
111
  .captureCharFrame()
112
112
  .split("\n")
113
113
  .find((row) => row.includes("[comment] [approve] [changes]"));
114
+
114
115
  await press(setup, "right");
115
116
  const commentSelector = setup
116
117
  .captureCharFrame()
@@ -220,6 +221,7 @@ describe("send message confirm", () => {
220
221
  // Arrange
221
222
  seedAnnotations(1);
222
223
  const setup = await renderApp();
224
+
223
225
  await pressKey(setup, "RETURN", { meta: true });
224
226
  await waitForText(setup, "[approve]");
225
227
 
@@ -233,6 +235,7 @@ describe("send message confirm", () => {
233
235
  test("pasting an image into the summary drops in an [Image #n] placeholder", async () => {
234
236
  // Arrange
235
237
  const setup = await renderApp();
238
+
236
239
  await pressKey(setup, "RETURN", { meta: true });
237
240
  await waitForText(setup, "[approve]");
238
241
 
@@ -378,6 +378,7 @@ describe("no-thread shell", () => {
378
378
  // unique "rename" row - the star action sits directly above it
379
379
  await waitForText(setup, "rename");
380
380
  const rename = locateText(setup, "rename");
381
+
381
382
  await setup.mockMouse.click(rename.column, rename.row - 1);
382
383
 
383
384
  // Assert - a Starred section now holds the thread
@@ -0,0 +1,12 @@
1
+ import { expect, test } from "bun:test";
2
+ import { canRunThreadAgent } from "./App";
3
+
4
+ test("experimental agent UI requires a local owner and an enabled flag", () => {
5
+ const local = { enabled: true, owner: true, editing: false, shared: false };
6
+
7
+ expect(canRunThreadAgent(local)).toBe(true);
8
+ expect(canRunThreadAgent({ ...local, enabled: false })).toBe(false);
9
+ expect(canRunThreadAgent({ ...local, owner: false })).toBe(false);
10
+ expect(canRunThreadAgent({ ...local, editing: true })).toBe(false);
11
+ expect(canRunThreadAgent({ ...local, shared: true })).toBe(false);
12
+ });
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 {
@@ -371,6 +376,7 @@ function ProjectPanelBody(props: {
371
376
  />
372
377
  );
373
378
  }
379
+
374
380
  return (
375
381
  <ProjectTreeView
376
382
  key={props.diffSourceKey}
@@ -612,6 +618,7 @@ export function App({
612
618
  // A shared plan you own polls for collaborator notes while it is open; the
613
619
  // merge refreshes through the normal event path. Stops on leave.
614
620
  const threadShared = isSharedThread(session);
621
+
615
622
  useEffect(() => {
616
623
  if (!isOwner || !threadShared) return;
617
624
 
@@ -650,6 +657,7 @@ export function App({
650
657
  const [focusedPane, setFocusedPane] = useState<FocusPane>(() =>
651
658
  initialFocusedPane(sessionId, workbench.zoomed, layout),
652
659
  );
660
+
653
661
  // only the session view persists from here; the bare shell owns its own (NoThreadShell), so this
654
662
  // workbench stays inactive with no session and never clobbers what the shell saved
655
663
  useRememberLayout(
@@ -697,11 +705,13 @@ export function App({
697
705
  );
698
706
  // opt-in: render a prototype as a kitty pixel mockup instead of the markdown doc
699
707
  const [prototypePixels, setPrototypePixels] = useState(false);
708
+ const [threadAgentEnabled, setThreadAgentEnabled] = useState(false);
700
709
 
701
710
  useEffect(() => {
702
711
  const config = loadConfig({ repoRoot: session?.workspace.repoRoot });
703
712
 
704
713
  setPrototypePixels(config.experimental.prototypePixels);
714
+ setThreadAgentEnabled(config.experimental.threadAgent);
705
715
  keysRef.current = config.keys;
706
716
  keyBindings.setKeys(config.keys);
707
717
  setTheme(composeTheme(config.ui.theme, config.themeOverrides, appearance));
@@ -758,6 +768,7 @@ export function App({
758
768
  };
759
769
  const syncGithubIdentity = (): void => {
760
770
  const generation = (identityGenerationRef.current += 1);
771
+
761
772
  void resolveGithubIdentity().then((github) => {
762
773
  if (identityGenerationRef.current !== generation) return;
763
774
  if (!github) return controller.setStatus("GitHub not connected - run gh auth login");
@@ -843,12 +854,20 @@ export function App({
843
854
  () => (session && isDiff ? commentCountsByFile(session, rows) : undefined),
844
855
  [session, isDiff, rows],
845
856
  );
857
+
846
858
  // entering a diff opens the right region in changed-files mode; a plan or reply opens it closed
847
859
  workbench.syncSession(session?.id, isDiff);
848
860
  // plans, replies, and the default (markdown) prototype open in the thread view, diffs in the
849
861
  // diff sheet: both drive the shared annotation surface; only the pixel prototype keeps the keymap
850
862
  const threadViewActive = session !== null && !isPixelPrototype;
851
863
  const [threadComposing, setThreadComposing] = useState(false);
864
+ const [agentActive, setAgentActive] = useState(false);
865
+ const [agentControls, setAgentControls] = useState<React.ReactNode>(null);
866
+ const agentActions = useRef<(id: string) => { label: string; run: () => void } | undefined>(
867
+ () => undefined,
868
+ );
869
+ const agentInvoke = useRef<(() => void) | undefined>(undefined);
870
+ const [agentState, setAgentState] = useState<import("@cueloop/schema").ThreadAgentState>();
852
871
  const [prototypeComposing, setPrototypeComposing] = useState(false);
853
872
  const [welcomeComposing, setWelcomeComposing] = useState(false);
854
873
  // inline body edit: the markdown editor owns the thread pane and all keys while open
@@ -975,6 +994,7 @@ export function App({
975
994
  workbench.changesOpen,
976
995
  workbench.projectOpen,
977
996
  );
997
+
978
998
  useEffect(() => {
979
999
  // the bare shell drives its panes from its own workbench, so only reconcile against this one
980
1000
  if (!session) return;
@@ -1020,7 +1040,28 @@ export function App({
1020
1040
  controller.open(id);
1021
1041
  };
1022
1042
 
1043
+ const createEmptyThread = enabledThreadAction(
1044
+ canRunThreadAgent({
1045
+ enabled: threadAgentEnabled,
1046
+ owner: isOwner,
1047
+ editing: false,
1048
+ shared: isSharedThreadConnection(openClient, shareTransport),
1049
+ }),
1050
+ () => {
1051
+ void controller.createEmptyThread();
1052
+ setFocusedPane("thread");
1053
+ },
1054
+ );
1055
+
1023
1056
  const threadSurfaceHandledKey = (key: KeyEvent): boolean => {
1057
+ if (
1058
+ agentOwnsKeyboard(agentActive, focusedPane) &&
1059
+ (key.name === "return" || key.name === "enter") &&
1060
+ key.ctrl &&
1061
+ !key.meta &&
1062
+ !key.super
1063
+ )
1064
+ return true;
1024
1065
  if (!threadViewActive || threadViewSuspended) return false;
1025
1066
  if (!threadComposing) {
1026
1067
  const chord = resolveSessionChord(key, { isOwner, resolved });
@@ -1130,6 +1171,7 @@ export function App({
1130
1171
  rows={grouped.rows}
1131
1172
  inboxCursor={inboxCursor}
1132
1173
  onOpenThread={openThread}
1174
+ onCreateThread={createEmptyThread}
1133
1175
  mode={mode}
1134
1176
  theme={theme}
1135
1177
  controller={controller}
@@ -1225,6 +1267,7 @@ export function App({
1225
1267
 
1226
1268
  const threadFooter = (
1227
1269
  <ThreadFooter
1270
+ controls={threadAgentControls(agentActive, agentControls)}
1228
1271
  repo={projectName(activeSession.workspace)}
1229
1272
  branch={activeSession.workspace.branch}
1230
1273
  onSubmit={onSubmitRequest}
@@ -1265,6 +1308,7 @@ export function App({
1265
1308
  pinnedIds={pinnedIds}
1266
1309
  width={30}
1267
1310
  onSelect={openThread}
1311
+ onCreateThread={createEmptyThread}
1268
1312
  onPin={togglePin}
1269
1313
  onRename={(id, title) =>
1270
1314
  setMode({ type: "renameThread", sessionId: id, text: title })
@@ -1278,106 +1322,138 @@ export function App({
1278
1322
  }
1279
1323
  threadTitle={reviewThreadTitle(activeSession)}
1280
1324
  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
1325
+ <>
1326
+ {showOwnerActions
1327
+ ? ownerThreadActions({
1328
+ editing: bodyEditing.editing,
1329
+ onEdit: onEditRequest,
1330
+ onExitEdit: bodyEditing.requestExit,
1331
+ onShare: () => dispatch({ type: "share" }),
1332
+ theme,
1333
+ })
1334
+ : showDiffRefresh
1335
+ ? refreshDiffAction(() => void controller.refreshDiff(), theme)
1336
+ : undefined}
1337
+ </>
1292
1338
  }
1293
1339
  threadPanel={
1294
1340
  <box style={{ flexGrow: 1, flexDirection: "column" }}>
1295
1341
  <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
- })}
1342
+ <AgentThreadPrototype
1343
+ key={activeSession.id}
1344
+ enabled={canRunThreadAgent({
1345
+ enabled: threadAgentEnabled,
1346
+ owner: isOwner,
1347
+ editing: bodyEditing.editing,
1348
+ shared: isSharedThreadConnection(openClient, shareTransport),
1349
+ })}
1350
+ observer={observer}
1351
+ pixelPrototype={isPixelPrototype}
1352
+ thread={activeSession}
1353
+ home={home}
1354
+ theme={theme}
1355
+ focused={focusedPane === "thread"}
1356
+ suspended={threadViewSuspended}
1357
+ onActiveChange={setAgentActive}
1358
+ onControlsChange={setAgentControls}
1359
+ onStateChange={setAgentState}
1360
+ onActionsChange={(actions) => {
1361
+ agentActions.current = actions;
1362
+ }}
1363
+ onInvokeChange={(invoke) => {
1364
+ agentInvoke.current = invoke;
1365
+ }}
1366
+ onRevealReply={() => setFocusedPane("thread")}
1367
+ flushMutations={() => flushReviewMutations(controller)}
1368
+ onOpenFile={(path) => workbench.openFile(path, "contents", true)}
1369
+ onNextPane={cyclePanes}
1370
+ >
1371
+ {chooseThreadBody({
1372
+ isPixelPrototype,
1373
+ isDiff,
1374
+ editingBody: bodyEditing.editing,
1375
+ prototype: (
1376
+ <PrototypePixels
1377
+ prototypePath={prototypePath}
1378
+ canComment={prototypeCanComment}
1379
+ onCommentElement={onCommentPrototype}
1380
+ onComposingChange={setPrototypeComposing}
1381
+ hidden={chromeHidden}
1382
+ />
1383
+ ),
1384
+ diffPlaceholder: activeSession.artifact.meta.prBrief ? (
1385
+ <ThreadView
1386
+ session={pullRequestBriefThread(activeSession)}
1387
+ display={buildDisplay(activeSession.artifact.meta.prBrief)}
1388
+ marks={new Map()}
1389
+ quickActions={quickActions}
1390
+ suspended={threadViewSuspended}
1391
+ resolved
1392
+ observer
1393
+ onComposingChange={() => {}}
1394
+ onObserverBlocked={() => {}}
1395
+ onCursorChange={() => {}}
1396
+ onAnnotate={() => {}}
1397
+ onReply={() => {}}
1398
+ onUpdateAnnotation={() => {}}
1399
+ resolveAuthorLabel={() => undefined}
1400
+ onExit={() => onExit?.(0)}
1401
+ />
1402
+ ) : (
1403
+ <box
1404
+ style={{
1405
+ flexGrow: 1,
1406
+ flexDirection: "column",
1407
+ alignItems: "center",
1408
+ justifyContent: "center",
1409
+ }}
1410
+ >
1411
+ <text fg={theme.textDim}>Select a thread</text>
1412
+ </box>
1413
+ ),
1414
+ editor: bodyEditing.renderEditor(theme),
1415
+ threadView: (
1416
+ <ThreadView
1417
+ session={activeSession}
1418
+ suspended={surfaceSuspended(threadViewSuspended, focusedPane, "thread")}
1419
+ editOrphanCount={editOrphanCount}
1420
+ onComposingChange={setThreadComposing}
1421
+ onNavCommand={runNavCommand}
1422
+ resolved={resolved}
1423
+ onObserverBlocked={(reason) =>
1424
+ controller.setStatus(
1425
+ reason === "observer"
1426
+ ? "observer - read-only"
1427
+ : "review submitted - read-only",
1428
+ )
1429
+ }
1430
+ onCursorChange={setCursor}
1431
+ focusedAnnotationId={focusedAnnotationId}
1432
+ onFocusAnnotation={setFocusedAnnotationId}
1433
+ display={display}
1434
+ marks={marks}
1435
+ quickActions={quickActions}
1436
+ observer={observer}
1437
+ onAnnotate={(span, body) =>
1438
+ controller.annotate(
1439
+ "comment",
1440
+ span.start.blockIndex,
1441
+ span.start.char,
1442
+ span.end.char,
1443
+ body,
1444
+ span.end.blockIndex,
1445
+ )
1446
+ }
1447
+ onReply={(rootAnnotationId, body) =>
1448
+ controller.reply(rootAnnotationId, body)
1449
+ }
1450
+ onUpdateAnnotation={(id, body) => controller.updateAnnotation(id, body)}
1451
+ resolveAuthorLabel={resolveAuthorLabel}
1452
+ onExit={() => onExit?.(0)}
1453
+ />
1454
+ ),
1455
+ })}
1456
+ </AgentThreadPrototype>
1381
1457
  </box>
1382
1458
  {threadFooter}
1383
1459
  </box>
@@ -1444,6 +1520,15 @@ export function App({
1444
1520
  onReply: (rootAnnotationId, body) =>
1445
1521
  void controller.reply(rootAnnotationId, body),
1446
1522
  onUpdateAnnotation: (id, body) => controller.updateAnnotation(id, body),
1523
+ onInvoke: agentActive
1524
+ ? () => {
1525
+ agentInvoke.current?.();
1526
+ setFocusedPane("thread");
1527
+ }
1528
+ : undefined,
1529
+ annotationAction: (id) => agentActions.current(id),
1530
+ isAnnotationReadOnly: (id) =>
1531
+ isSubmittedComment(agentState, activeSession.id, id),
1447
1532
  resolveAuthorLabel,
1448
1533
  onNavCommand: runNavCommand,
1449
1534
  onExit: () => onExit?.(0),
@@ -1548,3 +1633,44 @@ export function App({
1548
1633
  </SlashSkillsContext.Provider>
1549
1634
  );
1550
1635
  }
1636
+
1637
+ function enabledThreadAction(enabled: boolean, action: () => void): (() => void) | undefined {
1638
+ return enabled ? action : undefined;
1639
+ }
1640
+
1641
+ /** Experimental agent UI is available only in a local owner's review. */
1642
+ export function canRunThreadAgent(options: {
1643
+ enabled: boolean;
1644
+ owner: boolean;
1645
+ editing: boolean;
1646
+ shared: boolean;
1647
+ }): boolean {
1648
+ return options.enabled && options.owner && !options.editing && !options.shared;
1649
+ }
1650
+
1651
+ function isSubmittedComment(
1652
+ state: import("@cueloop/schema").ThreadAgentState | undefined,
1653
+ threadId: string,
1654
+ commentId: string,
1655
+ ): boolean {
1656
+ return (
1657
+ state?.threadId === threadId &&
1658
+ Boolean(state.submissions?.some((entry) => entry.commentId === commentId))
1659
+ );
1660
+ }
1661
+
1662
+ function flushReviewMutations(controller: ReviewController): Promise<void> {
1663
+ return controller.flushMutations?.() ?? Promise.resolve();
1664
+ }
1665
+
1666
+ function threadAgentControls(active: boolean, controls: React.ReactNode): React.ReactNode {
1667
+ return active ? controls : undefined;
1668
+ }
1669
+
1670
+ /** Injected shared transports never own a local harness connection. */
1671
+ function isSharedThreadConnection(
1672
+ openClient: AppProps["openClient"],
1673
+ shareTransport: ShareTransport | undefined,
1674
+ ): boolean {
1675
+ return Boolean(openClient || shareTransport);
1676
+ }
@@ -33,6 +33,7 @@ function hasSpanColor(
33
33
  for (const span of line.spans) {
34
34
  if (!span.text.includes(needle) || !span.fg) continue;
35
35
  const [red, green, blue] = span.fg.toInts();
36
+
36
37
  if (red === color[0] && green === color[1] && blue === color[2]) return true;
37
38
  }
38
39
  }
@@ -47,6 +48,7 @@ for (const mode of ["dark", "light"] as const) {
47
48
  width: 120,
48
49
  height: 32,
49
50
  });
51
+
50
52
  await waitForText(setup, "Second review");
51
53
 
52
54
  await clickText(setup, "Second review");