@cueloop/client 0.1.0-alpha.67 → 0.1.0-alpha.69

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 (85) hide show
  1. package/package.json +7 -6
  2. package/src/App.appearance.test.tsx +8 -6
  3. package/src/App.diff.test.tsx +3 -3
  4. package/src/App.export.test.tsx +9 -3
  5. package/src/App.plan-review.test.tsx +10 -8
  6. package/src/App.prototype-markdown.test.tsx +92 -0
  7. package/src/App.prototype.test.tsx +17 -28
  8. package/src/App.readonly.test.tsx +3 -3
  9. package/src/App.rendering.test.tsx +3 -4
  10. package/src/App.test.tsx +14 -18
  11. package/src/App.tsx +117 -42
  12. package/src/App.walk.test.tsx +2 -2
  13. package/src/App.workbench.test.tsx +85 -14
  14. package/src/app-screens.tsx +26 -10
  15. package/src/app-view-model.ts +17 -3
  16. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  17. package/src/components/AnnotatableFileView.test.tsx +102 -0
  18. package/src/components/AnnotatableFileView.tsx +126 -0
  19. package/src/components/AnnotationCards.tsx +6 -6
  20. package/src/components/AppShell.tsx +62 -15
  21. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  22. package/src/components/BareWorkbenchFileView.tsx +69 -0
  23. package/src/components/ChangesColumn.tsx +3 -2
  24. package/src/components/{DiffSheet.scroll.test.tsx → DiffContentView.scroll.test.tsx} +2 -2
  25. package/src/components/{DiffSheet.split.test.tsx → DiffContentView.split.test.tsx} +3 -3
  26. package/src/components/{DiffSheet.stories.tsx → DiffContentView.stories.tsx} +3 -3
  27. package/src/components/{DiffSheet.syntax.test.tsx → DiffContentView.syntax.test.tsx} +2 -2
  28. package/src/components/{DiffSheet.tsx → DiffContentView.tsx} +187 -28
  29. package/src/components/DiscussionMarkerRail.tsx +2 -2
  30. package/src/components/EditorGrid.tabs.test.tsx +17 -4
  31. package/src/components/EditorGrid.tsx +49 -19
  32. package/src/components/FileContentsView.test.tsx +3 -3
  33. package/src/components/FileContentsView.tsx +37 -17
  34. package/src/components/{SurfaceScrollbar.stories.tsx → OverlayScrollbar.stories.tsx} +5 -6
  35. package/src/components/{SurfaceScrollbar.test.tsx → OverlayScrollbar.test.tsx} +41 -13
  36. package/src/components/{SurfaceScrollbar.tsx → OverlayScrollbar.tsx} +49 -13
  37. package/src/components/ProjectTreeView.tsx +3 -2
  38. package/src/components/{PrototypeSheet.stories.tsx → PrototypeContentView.stories.tsx} +4 -5
  39. package/src/components/{PrototypeSheet.tsx → PrototypeContentView.tsx} +58 -58
  40. package/src/components/ScrollArea.stories.tsx +20 -0
  41. package/src/components/ScrollArea.tsx +44 -0
  42. package/src/components/SettingsDialog.tsx +3 -2
  43. package/src/components/{InboxList.stories.tsx → ThreadTree.stories.tsx} +5 -5
  44. package/src/components/{InboxList.tsx → ThreadTree.tsx} +6 -6
  45. package/src/components/ThreadsSidebar.tsx +5 -4
  46. package/src/components/TreePane.tsx +3 -2
  47. package/src/components/WelcomePlayground.stories.tsx +12 -0
  48. package/src/components/WelcomePlayground.test.tsx +47 -0
  49. package/src/components/WelcomePlayground.tsx +140 -0
  50. package/src/components/__snapshots__/stories.test.tsx.snap +163 -283
  51. package/src/components/{diff-sheet-layout.test.ts → diff-content-view-layout.test.ts} +1 -1
  52. package/src/components/{diff-sheet-layout.ts → diff-content-view-layout.ts} +1 -1
  53. package/src/config.test.ts +19 -6
  54. package/src/config.ts +26 -6
  55. package/src/ghostty-terminal.test.ts +2 -1
  56. package/src/ghostty-terminal.ts +27 -4
  57. package/src/intent-dispatch.test.ts +2 -0
  58. package/src/intent-dispatch.ts +1 -1
  59. package/src/key-bindings.test.ts +1 -1
  60. package/src/key-bindings.ts +5 -1
  61. package/src/keymap.ts +1 -1
  62. package/src/prototype-browser.ts +8 -0
  63. package/src/prototype-pixels.tsx +35 -0
  64. package/src/ready-signal.test.ts +85 -0
  65. package/src/ready-signal.ts +52 -0
  66. package/src/session-controller.live-diff.test.ts +198 -0
  67. package/src/session-controller.ts +141 -26
  68. package/src/slash-palette.test.ts +54 -0
  69. package/src/slash-palette.ts +32 -18
  70. package/src/test-support.test.ts +12 -0
  71. package/src/test-support.ts +69 -10
  72. package/src/theme-presets.ts +20 -0
  73. package/src/theme.ts +18 -0
  74. package/src/thread-chords.ts +1 -1
  75. package/src/use-annotation-surface.tsx +12 -2
  76. package/src/use-settings-dialog.tsx +4 -4
  77. package/src/view-diff.test.ts +44 -1
  78. package/src/view-diff.ts +81 -0
  79. package/src/components/AnnotationCard.compose-rows.test.ts +0 -49
  80. package/src/components/AnnotationCard.stories.tsx +0 -94
  81. package/src/components/AnnotationCard.tsx +0 -250
  82. package/src/components/MarkerPopover.stories.tsx +0 -58
  83. package/src/components/MarkerPopover.tsx +0 -109
  84. package/src/components/WelcomeSurface.stories.tsx +0 -12
  85. package/src/components/WelcomeSurface.tsx +0 -52
@@ -0,0 +1,198 @@
1
+ /**
2
+ * A non-diff thread (here a plan) still renders a real diff in the Changes view:
3
+ * the controller eagerly captures the live working-tree diff, so repoChanges()
4
+ * carries file contents and rows() derives from the patch - the same shape a
5
+ * cueloop diff pins, just computed live.
6
+ */
7
+
8
+ import { describe, expect, mock, test } from "bun:test";
9
+ import { SCHEMA_VERSION, type Annotation, type ReviewSession } from "@cueloop/schema";
10
+ import type { SessionClient } from "@cueloop/daemon/client";
11
+ import { createReviewController, type ShareTransport } from "./session-controller";
12
+ import { mergeFromShare } from "./share";
13
+
14
+ const AT = "2026-01-01T00:00:00.000Z";
15
+
16
+ const PATCH = `diff --git a/src/x.ts b/src/x.ts
17
+ --- a/src/x.ts
18
+ +++ b/src/x.ts
19
+ @@ -1,1 +1,1 @@
20
+ -const a = 1;
21
+ +const a = 2;
22
+ `;
23
+
24
+ const FILES = [
25
+ {
26
+ path: "src/x.ts",
27
+ oldContents: "const a = 1;\n",
28
+ newContents: "const a = 2;\n",
29
+ status: "modified" as const,
30
+ },
31
+ ];
32
+
33
+ const shareTransport: ShareTransport = {
34
+ publish: mock(async (session: ReviewSession) => ({
35
+ line: `ssh p_${session.id}@cueloop.dev`,
36
+ copied: true,
37
+ })),
38
+ pull: mock(async () => planSession()),
39
+ push: mock(async () => {}),
40
+ watch: () => () => {},
41
+ parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
42
+ collaboratorAnnotations: (session) => session.annotations.filter((entry) => entry.author),
43
+ mergeFromShare,
44
+ };
45
+
46
+ function planSession(id = "ses_plan", repoRoot = "/repo"): ReviewSession {
47
+ return {
48
+ schemaVersion: SCHEMA_VERSION,
49
+ id,
50
+ workspace: { repoRoot, branch: "main" },
51
+ artifact: { type: "plan", content: "# Plan\n", meta: {} },
52
+ revisions: [{ revision: 1, content: "# Plan\n", submittedAt: AT }],
53
+ annotations: [],
54
+ verdict: null,
55
+ status: "pending",
56
+ createdAt: AT,
57
+ };
58
+ }
59
+
60
+ const unimplemented = (member: string) => () =>
61
+ Promise.reject(new Error(`fakeClient does not implement ${member}`));
62
+
63
+ function fakeClient(session: ReviewSession): SessionClient {
64
+ return {
65
+ onEvent: () => () => {},
66
+ subscribe: async () => {},
67
+ sessionGet: async () => session,
68
+ sessionList: async () => [session],
69
+ sessionAnnotate: unimplemented("sessionAnnotate"),
70
+ sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
71
+ sessionSetWorkingCopy: unimplemented("sessionSetWorkingCopy"),
72
+ sessionCutBlock: unimplemented("sessionCutBlock"),
73
+ sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
74
+ sessionCurate: unimplemented("sessionCurate"),
75
+ sessionNavigate: unimplemented("sessionNavigate"),
76
+ sessionBranch: unimplemented("sessionBranch"),
77
+ sessionSwitch: unimplemented("sessionSwitch"),
78
+ sessionLabel: unimplemented("sessionLabel"),
79
+ sessionFork: unimplemented("sessionFork"),
80
+ sessionSetViewed: unimplemented("sessionSetViewed"),
81
+ sessionSetTitle: unimplemented("sessionSetTitle"),
82
+ sessionSetShareId: unimplemented("sessionSetShareId"),
83
+ sessionMergeShared: unimplemented("sessionMergeShared"),
84
+ sessionDelete: unimplemented("sessionDelete"),
85
+ sessionSetSelfName: unimplemented("sessionSetSelfName"),
86
+ sessionResolve: unimplemented("sessionResolve"),
87
+ repoDiff: async () => ({ patch: PATCH, files: FILES }),
88
+ close: () => {},
89
+ } satisfies SessionClient;
90
+ }
91
+
92
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
93
+
94
+ describe("live working-tree diff for a non-diff thread", () => {
95
+ test("repoChanges carries file contents and rows derive from the patch", async () => {
96
+ const session = planSession();
97
+ const controller = createReviewController({
98
+ sessionId: session.id,
99
+ openClient: async () => fakeClient(session),
100
+ shareTransport,
101
+ });
102
+ controller.connect();
103
+ await tick();
104
+
105
+ // Act - the Changes navigator loads
106
+ const changes = await controller.repoChanges();
107
+
108
+ // Assert - real per-file contents, not empty stubs
109
+ expect(changes.map((file) => file.path)).toEqual(["src/x.ts"]);
110
+ expect(changes[0]!.newContents).toBe("const a = 2;\n");
111
+
112
+ // and the diff rows derive from the live patch, so an opened file tab shows a diff
113
+ expect(controller.rows().some((row) => row.file === "src/x.ts")).toBe(true);
114
+
115
+ // the live per-file contents let a non-diff thread expand a folded file too
116
+ expect(controller.canExpandFile("src/x.ts")).toBe(true);
117
+
118
+ controller.close();
119
+ });
120
+
121
+ test("annotating the Changes diff stamps a file target anchored to the diff rows", async () => {
122
+ const session = planSession();
123
+ let sent: Annotation | undefined;
124
+ const client = {
125
+ ...fakeClient(session),
126
+ sessionAnnotate: async (_id: string, annotation: Annotation) => {
127
+ sent = annotation;
128
+
129
+ return { ...session, annotations: [{ ...annotation, createdAt: AT }] };
130
+ },
131
+ } satisfies SessionClient;
132
+ const controller = createReviewController({
133
+ sessionId: session.id,
134
+ openClient: async () => client,
135
+ shareTransport,
136
+ });
137
+ controller.connect();
138
+ await tick();
139
+ await controller.repoChanges();
140
+
141
+ // Act - comment the added line of the working-tree diff, as the Changes surface does
142
+ const rows = controller.rows();
143
+ const addIndex = rows.findIndex((row) => row.kind === "add");
144
+ const addText = rows[addIndex]!.text.replace(/\n$/, "");
145
+
146
+ controller.annotate("comment", addIndex, 0, addText.length, "eviction?", addIndex, {
147
+ kind: "file",
148
+ path: "",
149
+ rev: "worktree",
150
+ });
151
+
152
+ // Assert - the note carries the file target (path and rev filled from the row) and quotes the diff
153
+ expect(sent?.target).toEqual({ kind: "file", path: "src/x.ts", rev: "worktree" });
154
+ expect(sent?.anchor.quote).toContain("const a = 2");
155
+
156
+ controller.close();
157
+ });
158
+
159
+ test("a late diff response from a previous thread never overwrites the active one", async () => {
160
+ const sessionA = planSession("ses_A", "/repoA");
161
+ const sessionB = planSession("ses_B", "/repoB");
162
+ let resolveA: (diff: { patch: string; files: typeof FILES }) => void = () => {};
163
+ const pendingA = new Promise<{ patch: string; files: typeof FILES }>((resolve) => {
164
+ resolveA = resolve;
165
+ });
166
+ const diffB = {
167
+ patch: PATCH.replace(/x\.ts/g, "y.ts"),
168
+ files: [{ ...FILES[0]!, path: "src/y.ts" }],
169
+ };
170
+ const client = {
171
+ ...fakeClient(sessionA),
172
+ sessionList: async () => [sessionA, sessionB],
173
+ repoDiff: async (cwd: string) => (cwd === "/repoA" ? pendingA : diffB),
174
+ } satisfies SessionClient;
175
+
176
+ const controller = createReviewController({
177
+ sessionId: sessionA.id,
178
+ openClient: async () => client,
179
+ shareTransport,
180
+ });
181
+ controller.connect();
182
+ await tick();
183
+
184
+ // thread A's diff is still in flight when the user switches to thread B
185
+ const inFlightA = controller.repoChanges();
186
+ controller.open("ses_B");
187
+ await controller.repoChanges();
188
+ // B's diff is showing; now A's stale response lands
189
+ resolveA({ patch: PATCH, files: FILES });
190
+ await inFlightA;
191
+
192
+ // the active thread keeps B's changes; A's late response is dropped
193
+ expect(controller.rows().some((row) => row.file === "src/y.ts")).toBe(true);
194
+ expect(controller.rows().some((row) => row.file === "src/x.ts")).toBe(false);
195
+
196
+ controller.close();
197
+ });
198
+ });
@@ -26,7 +26,9 @@ import {
26
26
  returnPaneFor,
27
27
  switchBranch,
28
28
  viewOfPath,
29
+ type Anchor,
29
30
  type Annotation,
31
+ type AnnotationTarget,
30
32
  type DiffFileContents,
31
33
  type ReviewSession,
32
34
  type SessionHistory,
@@ -44,7 +46,7 @@ import {
44
46
  } from "./share";
45
47
  import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view-plan";
46
48
  import { entryTarget, treeRows, type TreeRow } from "./tree-view";
47
- import { diffRowBlocks, diffRows, fileChangeCounts, type DiffRow } from "./view-diff";
49
+ import { diffRowBlocks, diffRows, fileChangeCounts, fileRowRange, type DiffRow } from "./view-diff";
48
50
  import { applyFold } from "./diff-fold";
49
51
  import { copyToClipboard } from "./clipboard";
50
52
  import {
@@ -60,7 +62,8 @@ import type { FileDiffMetadata } from "@pierre/diffs";
60
62
  import { firstUnviewedIndex, walkFiles, type WalkFile } from "./walk";
61
63
  import { editInEditor } from "./editor";
62
64
  import { focusHerdrPane } from "./herdr";
63
- import { persistAutoClose, type AutoClose, type CueloopConfig } from "./config";
65
+ import { persistAutoClose, type AutoClose, type CueloopConfig, type QuickAction } from "./config";
66
+ import { slashItemsFrom } from "./slash-palette";
64
67
 
65
68
  /**
66
69
  * Post-submit lifecycle (a review pane should hand you back to the agent,
@@ -254,7 +257,17 @@ export interface ReviewController {
254
257
  end: number,
255
258
  body: string,
256
259
  endDisplayIndex?: number,
260
+ /** The surface being annotated; a `file` target anchors into the Changes diff rows. */
261
+ target?: AnnotationTarget,
257
262
  ): string | undefined;
263
+ /** Persist a comment whose anchor a surface already built (e.g. a file-contents view). */
264
+ addComment(anchor: Anchor, target: AnnotationTarget, body: string): string | undefined;
265
+ /**
266
+ * Comment from the bare launch shell, where no thread is open: find-or-create the per-repo
267
+ * workbench thread, open it (the shell becomes a thread view), then add the comment. This is the
268
+ * first write a browse-only launch makes - nothing is on disk until it runs.
269
+ */
270
+ commentOnWorkbench(anchor: Anchor, target: AnnotationTarget, body: string): Promise<void>;
258
271
  /**
259
272
  * Reply to `rootAnnotationId`: the reply shares the root's anchor and names
260
273
  * it in replyTo, so the discussion stays one conversation. Returns the minted id.
@@ -315,6 +328,12 @@ interface DerivedSessionProjection {
315
328
  tree: TreeRow[];
316
329
  }
317
330
 
331
+ /** A live working-tree diff: the same patch + per-file contents a diff capture pins. */
332
+ interface LiveWorkingDiff {
333
+ patch: string;
334
+ files: readonly DiffFileContents[];
335
+ }
336
+
318
337
  class Controller implements ReviewController {
319
338
  readonly readOnly: boolean;
320
339
  private client: SessionClient | null = null;
@@ -331,6 +350,7 @@ class Controller implements ReviewController {
331
350
  };
332
351
  private listeners = new Set<() => void>();
333
352
  private autoClose: AutoClose = "off";
353
+ private quickActions: QuickAction[] = [];
334
354
  private editor: string | undefined;
335
355
  private exporters: BundledExporter[] = [];
336
356
  private readonly clock: Clock;
@@ -339,8 +359,11 @@ class Controller implements ReviewController {
339
359
  private shareReconnect: TimerHandle | undefined;
340
360
  private shareStop: (() => void) | null = null;
341
361
  private shareRun: object | null = null;
342
- /** Projections keyed by session identity so renders reuse one computation. */
362
+ /** Projections keyed by session identity (and the live diff for non-diff threads) so renders reuse one computation. */
343
363
  private derivedFor: ReviewSession | null = null;
364
+ /** The launch/workspace repo's live working-tree diff, feeding the Changes view for non-diff threads. */
365
+ private liveDiff: LiveWorkingDiff | null = null;
366
+ private derivedForLiveDiff: LiveWorkingDiff | null = null;
344
367
  private derived: DerivedSessionProjection = {
345
368
  display: [],
346
369
  rows: [],
@@ -424,6 +447,7 @@ class Controller implements ReviewController {
424
447
 
425
448
  applyConfig(config: CueloopConfig): void {
426
449
  this.autoClose = config.ui.autoClose;
450
+ this.quickActions = config.actions;
427
451
  this.editor = config.ui.editor;
428
452
  void loadBundledExporters(config.integrations).then((exporters) => {
429
453
  this.exporters = exporters;
@@ -446,17 +470,25 @@ class Controller implements ReviewController {
446
470
  // ── derived projections ─────────────────────
447
471
  private ensureDerived(): void {
448
472
  const session = this.snapshot.session;
449
-
450
- if (this.derivedFor === session) return;
473
+ const isDiff = session?.artifact.type === "diff";
474
+ // a diff thread reads its pinned capture; every other thread reflects the live working tree
475
+ const liveDiff = isDiff ? null : this.liveDiff;
476
+
477
+ if (this.derivedFor === session && this.derivedForLiveDiff === liveDiff) return;
478
+ // fold state belongs to the session, so a live-tree refresh keeps it; only a new session resets it
479
+ if (this.derivedFor !== session) {
480
+ this.collapsedFiles.clear();
481
+ this.expandedFiles.clear();
482
+ }
451
483
  this.derivedFor = session;
452
- // a new session starts fully expanded, never inheriting the prior diff's fold state
453
- this.collapsedFiles.clear();
454
- this.expandedFiles.clear();
455
- const rows =
456
- session && session.artifact.type === "diff" ? diffRows(session.artifact.content) : [];
484
+ this.derivedForLiveDiff = liveDiff;
485
+
486
+ const content = isDiff ? session!.artifact.content : (liveDiff?.patch ?? "");
487
+ const files = isDiff ? (session?.artifact.files ?? []) : (liveDiff?.files ?? []);
488
+ const rows = content ? diffRows(content) : [];
457
489
  const models = new Map<string, FileDiffMetadata>();
458
490
 
459
- for (const file of session?.artifact.files ?? []) models.set(file.path, parseFileDiff(file));
491
+ for (const file of files) models.set(file.path, parseFileDiff(file));
460
492
  this.derived = {
461
493
  display: session ? buildDisplay(session.artifact.content, session.workingCopy) : [],
462
494
  rows,
@@ -466,6 +498,13 @@ class Controller implements ReviewController {
466
498
  };
467
499
  }
468
500
 
501
+ /** The per-file contents that back the current rows: a diff thread's capture, else the live working tree. */
502
+ private foldFiles(): readonly DiffFileContents[] | undefined {
503
+ const session = this.snapshot.session;
504
+
505
+ return session?.artifact.type === "diff" ? session.artifact.files : this.liveDiff?.files;
506
+ }
507
+
469
508
  treeRows(): TreeRow[] {
470
509
  this.ensureDerived();
471
510
 
@@ -482,12 +521,7 @@ class Controller implements ReviewController {
482
521
  this.ensureDerived();
483
522
  if (this.collapsedFiles.size === 0 && this.expandedFiles.size === 0) return this.derived.rows;
484
523
 
485
- return applyFold(
486
- this.derived.rows,
487
- this.collapsedFiles,
488
- this.expandedFiles,
489
- this.snapshot.session?.artifact.files,
490
- );
524
+ return applyFold(this.derived.rows, this.collapsedFiles, this.expandedFiles, this.foldFiles());
491
525
  }
492
526
 
493
527
  setFileCollapsed(file: string, collapsed: boolean): void {
@@ -519,7 +553,9 @@ class Controller implements ReviewController {
519
553
  }
520
554
 
521
555
  canExpandFile(file: string): boolean {
522
- return (this.snapshot.session?.artifact.files ?? []).some((entry) => entry.path === file);
556
+ // a live working-tree diff carries the same per-file contents as a captured one,
557
+ // so a non-diff thread can expand its files too
558
+ return (this.foldFiles() ?? []).some((entry) => entry.path === file);
523
559
  }
524
560
 
525
561
  copyFilePath(file: string): void {
@@ -581,6 +617,19 @@ class Controller implements ReviewController {
581
617
  // a diff review pins its captured snapshot; every other thread reflects the live working tree
582
618
  const session = this.snapshot.session;
583
619
  if (session?.artifact.type === "diff") return session.artifact.files ?? [];
620
+ // eager: capture the live working-tree diff so the Changes navigator and its file tabs render a real diff
621
+ if (this.client?.repoDiff !== undefined) {
622
+ const diff = await this.client.repoDiff(this.sidebarRepoRoot());
623
+ // a thread switch during the request would let this response overwrite the new
624
+ // thread's diff, showing changes from the wrong repo; drop it when the session moved on
625
+ if (this.snapshot.session !== session) return diff.files;
626
+ this.liveDiff = diff;
627
+ // rows() derive from the fresh patch; re-render so an open diff tab repaints
628
+ this.update({});
629
+
630
+ return diff.files;
631
+ }
632
+ // a peer without the live-diff capability still lists changes, just without a diff body
584
633
  if (this.client?.repoChanges === undefined) return [];
585
634
  const changes = await this.client.repoChanges(this.sidebarRepoRoot());
586
635
 
@@ -947,13 +996,37 @@ class Controller implements ReviewController {
947
996
  end: number,
948
997
  body: string,
949
998
  endDisplayIndex: number = displayIndex,
999
+ target: AnnotationTarget = { kind: "artifact" },
950
1000
  ): string | undefined {
951
1001
  const session = this.snapshot.session;
952
1002
 
953
1003
  if (!session) return undefined;
954
1004
  let anchor;
1005
+ let resolvedTarget = target;
1006
+ // a file target (a Changes-panel diff note) and a diff artifact both anchor into the diff rows
1007
+ const onDiff = target.kind === "file" || session.artifact.type === "diff";
1008
+
1009
+ if (target.kind === "file") {
1010
+ // a Changes-panel note anchors within its own file's rows, so a quote never attaches across
1011
+ // files; a selection that spills into the next file is clamped back to the file it started in
1012
+ const rows = this.rows();
1013
+ const startRow = rows[displayIndex];
1014
+ const path = startRow?.file ?? "";
1015
+ const range = fileRowRange(rows, path);
1016
+ const base = range?.start ?? 0;
1017
+ const fileRows = range ? rows.slice(range.start, range.end) : rows;
1018
+ const lastInFile = fileRows.length - 1;
1019
+ const clamp = (index: number): number => Math.max(0, Math.min(index - base, lastInFile));
955
1020
 
956
- if (session.artifact.type === "diff") {
1021
+ anchor = makeAnchor(
1022
+ diffRowBlocks(fileRows),
1023
+ clamp(displayIndex),
1024
+ start,
1025
+ end,
1026
+ clamp(endDisplayIndex),
1027
+ );
1028
+ resolvedTarget = { kind: "file", path, rev: startRow?.kind === "del" ? "head" : "worktree" };
1029
+ } else if (onDiff) {
957
1030
  // rows are the diff's blocks: a span over one or more code rows anchors with the
958
1031
  // same quote, context, and position selectors a plan span does
959
1032
  anchor = makeAnchor(diffRowBlocks(this.rows()), displayIndex, start, end, endDisplayIndex);
@@ -971,34 +1044,74 @@ class Controller implements ReviewController {
971
1044
  workIndexOf(endDisplayIndex),
972
1045
  );
973
1046
  }
974
- const wire = { id: newAnnotationId(), kind, anchor, body };
1047
+ // absent target means the reviewed artifact, so keep artifact notes free of the field
1048
+ const wire =
1049
+ resolvedTarget.kind === "artifact"
1050
+ ? { id: newAnnotationId(), kind, anchor, body }
1051
+ : { id: newAnnotationId(), kind, anchor, body, target: resolvedTarget };
975
1052
  const persisted = this.client!.sessionAnnotate(session.id, wire);
976
1053
 
977
1054
  this.apply(persisted);
978
1055
  this.mirrorAnnotation(persisted, wire);
979
- this.setStatus("comment added");
980
1056
 
981
1057
  return wire.id;
982
1058
  }
983
1059
 
1060
+ addComment(anchor: Anchor, target: AnnotationTarget, body: string): string | undefined {
1061
+ const session = this.snapshot.session;
1062
+
1063
+ if (!session) return undefined;
1064
+ const wire =
1065
+ target.kind === "artifact"
1066
+ ? { id: newAnnotationId(), kind: "comment", anchor, body }
1067
+ : { id: newAnnotationId(), kind: "comment", anchor, body, target };
1068
+ const persisted = this.client!.sessionAnnotate(session.id, wire);
1069
+
1070
+ this.apply(persisted);
1071
+ this.mirrorAnnotation(persisted, wire);
1072
+
1073
+ return wire.id;
1074
+ }
1075
+
1076
+ async commentOnWorkbench(anchor: Anchor, target: AnnotationTarget, body: string): Promise<void> {
1077
+ if (!this.snapshot.session) {
1078
+ if (this.client?.sessionWorkbench === undefined) return;
1079
+ let workbench;
1080
+
1081
+ try {
1082
+ workbench = await this.client.sessionWorkbench(this.options.cwd ?? process.cwd());
1083
+ } catch (cause) {
1084
+ // the composer already closed, so a lost first comment must at least surface, not vanish
1085
+ this.setStatus(String(cause instanceof Error ? cause.message : cause));
1086
+
1087
+ return;
1088
+ }
1089
+ // adopt the thread as active (the shell flips to the thread view) and let the inbox catch up
1090
+ this.update({ session: workbench });
1091
+ void this.refreshInbox();
1092
+ }
1093
+ this.addComment(anchor, target, body);
1094
+ }
1095
+
984
1096
  reply(rootAnnotationId: string, body: string): string | undefined {
985
1097
  const session = this.snapshot.session;
986
1098
  const root = session?.annotations.find((annotation) => annotation.id === rootAnnotationId);
987
1099
 
988
1100
  if (!session || !root) return undefined;
989
- // a reply to a reply still hangs off the discussion's root comment
990
- const wire = {
1101
+ // a reply to a reply still hangs off the discussion's root comment, and shares its target
1102
+ // so it renders and resolves on the same surface
1103
+ const base = {
991
1104
  id: newAnnotationId(),
992
1105
  kind: "comment",
993
1106
  anchor: root.anchor,
994
1107
  body,
995
1108
  replyTo: root.replyTo ?? root.id,
996
1109
  };
1110
+ const wire = root.target ? { ...base, target: root.target } : base;
997
1111
  const persisted = this.client!.sessionAnnotate(session.id, wire);
998
1112
 
999
1113
  this.apply(persisted);
1000
1114
  this.mirrorAnnotation(persisted, wire);
1001
- this.setStatus("reply added");
1002
1115
 
1003
1116
  return wire.id;
1004
1117
  }
@@ -1013,7 +1126,6 @@ class Controller implements ReviewController {
1013
1126
 
1014
1127
  this.apply(persisted);
1015
1128
  this.mirrorAnnotation(persisted, wire);
1016
- this.setStatus("comment added");
1017
1129
 
1018
1130
  return wire.id;
1019
1131
  }
@@ -1125,7 +1237,10 @@ class Controller implements ReviewController {
1125
1237
 
1126
1238
  if (!session) return;
1127
1239
  this.walkLeave();
1128
- this.client!.sessionResolve(session.id, verdict, summary)
1240
+ const actionBodies = Object.fromEntries(
1241
+ slashItemsFrom(this.quickActions).map((item) => [item.name, item.body]),
1242
+ );
1243
+ this.client!.sessionResolve(session.id, verdict, summary, actionBodies)
1129
1244
  .then((resolved) => {
1130
1245
  // The completion overlay heading already states the verdict, so the
1131
1246
  // status line stays empty here - only export/error messages fill it.
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { inlineSlashToken, scoreMatch, slashFilter, type SlashItem } from "./slash-palette";
3
+
4
+ function item(name: string): SlashItem {
5
+ return { name, description: name, body: name };
6
+ }
7
+
8
+ const ITEMS: SlashItem[] = [
9
+ item("zoom-out-research-in-depth"),
10
+ item("restate-simplified"),
11
+ item("out-of-scope"),
12
+ item("lgtm"),
13
+ ];
14
+
15
+ describe("scoreMatch", () => {
16
+ test("an exact name scores highest, then a prefix, then a subsequence", () => {
17
+ const exact = scoreMatch("lgtm", "lgtm")!;
18
+ const prefix = scoreMatch("lgtm-please", "lgtm")!;
19
+ const subsequence = scoreMatch("looking-good-to-me", "lgtm")!;
20
+
21
+ expect(exact).toBeGreaterThan(prefix);
22
+ expect(prefix).toBeGreaterThan(subsequence);
23
+ });
24
+
25
+ test("a query whose characters are not all present, in order, does not match", () => {
26
+ expect(scoreMatch("restate", "zzz")).toBeNull();
27
+ expect(scoreMatch("abc", "acb")).toBeNull();
28
+ });
29
+
30
+ test("an empty query matches everything", () => {
31
+ expect(scoreMatch("anything", "")).toBe(0);
32
+ });
33
+ });
34
+
35
+ describe("slashFilter", () => {
36
+ test("ranks a prefix match above a mere subsequence match", () => {
37
+ const ranked = slashFilter(ITEMS, "out");
38
+
39
+ expect(ranked[0]!.name).toBe("out-of-scope");
40
+ expect(ranked.map((entry) => entry.name)).toContain("zoom-out-research-in-depth");
41
+ });
42
+
43
+ test("drops items that do not match and returns all on an empty query", () => {
44
+ expect(slashFilter(ITEMS, "zzzz")).toEqual([]);
45
+ expect(slashFilter(ITEMS, "")).toHaveLength(ITEMS.length);
46
+ });
47
+ });
48
+
49
+ describe("inlineSlashToken", () => {
50
+ test("finds a trailing /word only when text precedes it", () => {
51
+ expect(inlineSlashToken("use /vitest-patt")).toBe("/vitest-patt");
52
+ expect(inlineSlashToken("/vitest-patt")).toBeNull();
53
+ });
54
+ });
@@ -23,28 +23,42 @@ export function slashItemsFrom(quickActions: QuickAction[]): SlashItem[] {
23
23
  }));
24
24
  }
25
25
 
26
- /** Prefix beats substring beats subsequence. */
27
- export function slashFilter(items: SlashItem[], query: string): SlashItem[] {
28
- const needle = query.toLowerCase();
26
+ const WORD_BOUNDARY = /[\s\-_./:]/;
27
+
28
+ /** Order-preserving subsequence score, or null when a query character is missing; higher is better. */
29
+ export function scoreMatch(name: string, query: string): number | null {
30
+ if (query.length === 0) return 0;
31
+ const lowerName = name.toLowerCase();
32
+ const lowerQuery = query.toLowerCase();
33
+
34
+ if (lowerName === lowerQuery) return 1000;
35
+ if (lowerName.startsWith(lowerQuery)) return 500 - name.length;
36
+ let score = 0;
37
+ let cursor = 0;
38
+ let run = 0;
39
+
40
+ for (const character of lowerQuery) {
41
+ const found = lowerName.indexOf(character, cursor);
42
+
43
+ if (found === -1) return null;
44
+ run = found === cursor ? run + 1 : 0;
45
+ score += found === cursor ? 5 + run : 1;
46
+ if (found === 0 || WORD_BOUNDARY.test(lowerName[found - 1]!)) score += 10;
47
+ score -= found - cursor;
48
+ cursor = found + 1;
49
+ }
50
+
51
+ return score;
52
+ }
29
53
 
30
- if (needle.length === 0) return items;
54
+ export function slashFilter(items: SlashItem[], query: string): SlashItem[] {
55
+ if (query.length === 0) return items;
31
56
  const scored: Array<{ item: SlashItem; score: number }> = [];
32
57
 
33
58
  for (const item of items) {
34
- const name = item.name.toLowerCase();
35
- let score = 0;
36
-
37
- if (name.startsWith(needle)) score = 3;
38
- else if (name.includes(needle)) score = 2;
39
- else {
40
- let matched = 0;
41
-
42
- for (const character of name) {
43
- if (character === needle[matched]) matched++;
44
- }
45
- if (matched === needle.length) score = 1;
46
- }
47
- if (score > 0) scored.push({ item, score });
59
+ const score = scoreMatch(item.name, query);
60
+
61
+ if (score !== null) scored.push({ item, score });
48
62
  }
49
63
 
50
64
  return scored.toSorted((left, right) => right.score - left.score).map((entry) => entry.item);
@@ -14,6 +14,7 @@ import {
14
14
  frameRow,
15
15
  isolateUserConfig,
16
16
  locateText,
17
+ locateTextInFrame,
17
18
  pressKey,
18
19
  waitForText,
19
20
  type FrameLocation,
@@ -250,3 +251,14 @@ describe("dragText", () => {
250
251
  expect(log.mouseEvents).toContainEqual({ type: "up", x: 9, y: 3 });
251
252
  });
252
253
  });
254
+
255
+ describe("locateTextInFrame", () => {
256
+ test("finds the 0-based row and column of the first match, null when absent", () => {
257
+ // Arrange
258
+ const frame = "header\n ● draft here\nfooter";
259
+
260
+ // Act + Assert
261
+ expect(locateTextInFrame(frame, "draft")).toEqual({ row: 1, column: 4 });
262
+ expect(locateTextInFrame(frame, "missing")).toBeNull();
263
+ });
264
+ });