@cueloop/client 0.1.0-alpha.33 → 0.1.0-alpha.35

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 (94) hide show
  1. package/package.json +27 -26
  2. package/src/App.autoclose.test.tsx +7 -4
  3. package/src/App.diff.test.tsx +130 -4
  4. package/src/App.export.test.tsx +22 -4
  5. package/src/App.plan-review.test.tsx +127 -27
  6. package/src/App.readonly.test.tsx +31 -8
  7. package/src/App.rendering.test.tsx +12 -3
  8. package/src/App.review-panel.test.tsx +11 -3
  9. package/src/App.submit-confirm.test.tsx +12 -7
  10. package/src/App.test.tsx +46 -9
  11. package/src/App.toast.test.tsx +10 -3
  12. package/src/App.tsx +400 -100
  13. package/src/App.walk.test.tsx +30 -10
  14. package/src/attribution.ts +8 -2
  15. package/src/clipboard.ts +5 -1
  16. package/src/components/AnnotationCard.stories.tsx +28 -9
  17. package/src/components/AnnotationCard.tsx +53 -38
  18. package/src/components/Breadcrumb.tsx +7 -1
  19. package/src/components/CodeBlock.stories.tsx +19 -2
  20. package/src/components/CodeBlock.tsx +24 -9
  21. package/src/components/CompletionOverlay.tsx +13 -3
  22. package/src/components/ConfirmCard.stories.tsx +22 -3
  23. package/src/components/ConfirmCard.tsx +22 -5
  24. package/src/components/ConfirmDialog.tsx +9 -1
  25. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  26. package/src/components/DiffSheet.stories.tsx +18 -2
  27. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  28. package/src/components/DiffSheet.tsx +184 -98
  29. package/src/components/InboxList.stories.tsx +16 -3
  30. package/src/components/InboxList.tsx +17 -10
  31. package/src/components/KeybindsDialog.tsx +19 -3
  32. package/src/components/MarkerPopover.stories.tsx +58 -0
  33. package/src/components/MarkerPopover.tsx +102 -0
  34. package/src/components/MenuBar.stories.tsx +5 -1
  35. package/src/components/MenuBar.tsx +18 -2
  36. package/src/components/PlanSheet.stories.tsx +34 -2
  37. package/src/components/PlanSheet.tsx +141 -32
  38. package/src/components/PromptDialog.tsx +18 -2
  39. package/src/components/ReviewPanel.stories.tsx +13 -3
  40. package/src/components/ReviewPanel.tsx +8 -4
  41. package/src/components/ReviewRail.stories.tsx +145 -72
  42. package/src/components/ReviewRail.tsx +177 -38
  43. package/src/components/SettingsDialog.stories.tsx +51 -3
  44. package/src/components/SettingsDialog.tsx +8 -3
  45. package/src/components/SettingsRows.tsx +19 -3
  46. package/src/components/Toast.tsx +17 -2
  47. package/src/components/WalkWizard.stories.tsx +13 -2
  48. package/src/components/WalkWizard.tsx +9 -3
  49. package/src/components/__snapshots__/stories.test.tsx.snap +281 -83
  50. package/src/components/diff-sheet-layout.test.ts +127 -0
  51. package/src/components/diff-sheet-layout.ts +167 -0
  52. package/src/components/primitives/Button.tsx +5 -1
  53. package/src/components/primitives/Dialog.tsx +9 -1
  54. package/src/components/primitives/Tabs.tsx +24 -4
  55. package/src/components/stories-app.tsx +34 -6
  56. package/src/components/story-fixtures.ts +11 -3
  57. package/src/components/story.ts +3 -1
  58. package/src/components/syntax-highlight.ts +47 -12
  59. package/src/components/theme-context.stories.tsx +6 -1
  60. package/src/components/theme-context.tsx +7 -1
  61. package/src/config.test.ts +178 -2
  62. package/src/config.ts +121 -23
  63. package/src/diff-hunk-curate.test.ts +315 -0
  64. package/src/diff-hunk-curate.ts +233 -0
  65. package/src/diff-intraline.test.ts +164 -0
  66. package/src/diff-intraline.ts +132 -0
  67. package/src/diff-syntax.test.ts +96 -0
  68. package/src/diff-syntax.ts +154 -0
  69. package/src/editor.test.ts +13 -4
  70. package/src/editor.ts +31 -5
  71. package/src/herdr.ts +8 -2
  72. package/src/index.ts +9 -1
  73. package/src/integrations.ts +3 -1
  74. package/src/intent-dispatch.test.ts +193 -9
  75. package/src/intent-dispatch.ts +151 -18
  76. package/src/key-bindings.test.ts +19 -9
  77. package/src/key-bindings.ts +36 -14
  78. package/src/keymap.test.ts +171 -26
  79. package/src/keymap.ts +83 -16
  80. package/src/review-panel.test.ts +3 -1
  81. package/src/review-panel.ts +9 -2
  82. package/src/serve.test.ts +11 -3
  83. package/src/session-controller.curate.test.ts +253 -0
  84. package/src/session-controller.share.test.ts +65 -17
  85. package/src/session-controller.ts +301 -26
  86. package/src/share.ts +41 -8
  87. package/src/test-support.ts +16 -6
  88. package/src/theme-presets.ts +168 -0
  89. package/src/theme.ts +1 -3
  90. package/src/view-diff.ts +15 -4
  91. package/src/view-plan.test.ts +40 -6
  92. package/src/view-plan.ts +71 -28
  93. package/src/walk.test.ts +7 -2
  94. package/src/walk.ts +7 -2
@@ -8,13 +8,16 @@ import type { SessionClient } from "@cueloop/daemon/client";
8
8
  const publishShare = mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: true }));
9
9
  let remote: ReviewSession;
10
10
  const pullShare = mock(async () => remote);
11
- const pushShare = mock(async (_shareId: string, _annotations: Array<Omit<Annotation, "createdAt">>) => {});
11
+ const pushShare = mock(
12
+ async (_shareId: string, _annotations: Array<Omit<Annotation, "createdAt">>) => {},
13
+ );
12
14
  mock.module("./share", () => ({
13
15
  publishShare,
14
16
  pullShare,
15
17
  pushShare,
16
18
  shareIdFromLine: (line: string) => line.match(/^ssh (\S+)@/)?.[1],
17
- collaboratorAnnotations: (session: ReviewSession) => session.annotations.filter((annotation) => annotation.author),
19
+ collaboratorAnnotations: (session: ReviewSession) =>
20
+ session.annotations.filter((annotation) => annotation.author),
18
21
  }));
19
22
 
20
23
  const { createReviewController, SHARE_POLL_MS } = await import("./session-controller");
@@ -35,7 +38,14 @@ function sessionFixture(overrides: Partial<ReviewSession> = {}): ReviewSession {
35
38
  }
36
39
 
37
40
  function annotation(id: string, author: string): Annotation {
38
- return { id, kind: "comment", anchor: { quote: "Plan", prefix: "", suffix: "" }, body: "note", author, createdAt: "2026-01-01T00:00:00.000Z" };
41
+ return {
42
+ id,
43
+ kind: "comment",
44
+ anchor: { quote: "Plan", prefix: "", suffix: "" },
45
+ body: "note",
46
+ author,
47
+ createdAt: "2026-01-01T00:00:00.000Z",
48
+ };
39
49
  }
40
50
 
41
51
  function fakeClient(session: ReviewSession): SessionClient {
@@ -45,10 +55,13 @@ function fakeClient(session: ReviewSession): SessionClient {
45
55
  sessionGet: async () => session,
46
56
  sessionList: async () => [session],
47
57
  sessionAnnotate: mock(async () => session),
48
- sessionSetShareId: mock(async (_id: string, shareId: string) => ((session.shareId = shareId), session)),
58
+ sessionSetShareId: mock(
59
+ async (_id: string, shareId: string) => ((session.shareId = shareId), session),
60
+ ),
49
61
  sessionMergeShared: mock(async (_id: string, incoming: { annotations: Annotation[] }) => {
50
62
  const known = new Set(session.annotations.map((existing) => existing.id));
51
- for (const note of incoming.annotations) if (!known.has(note.id)) session.annotations.push(note);
63
+ for (const note of incoming.annotations)
64
+ if (!known.has(note.id)) session.annotations.push(note);
52
65
  return session;
53
66
  }),
54
67
  close: () => {},
@@ -62,7 +75,11 @@ async function connectedController(
62
75
  clock?: ManualClock,
63
76
  ): Promise<{ controller: ReturnType<typeof createReviewController>; client: SessionClient }> {
64
77
  const client = fakeClient(session);
65
- const controller = createReviewController({ sessionId: session.id, openClient: async () => client, clock });
78
+ const controller = createReviewController({
79
+ sessionId: session.id,
80
+ openClient: async () => client,
81
+ clock,
82
+ });
66
83
  controller.connect();
67
84
  await tick();
68
85
  return { controller, client };
@@ -90,7 +107,10 @@ describe("share", () => {
90
107
  await tick();
91
108
 
92
109
  // Assert
93
- expect(controller.getSnapshot().toast).toEqual({ body: "ssh p_abc123xy@cueloop.dev", title: "share link copied" });
110
+ expect(controller.getSnapshot().toast).toEqual({
111
+ body: "ssh p_abc123xy@cueloop.dev",
112
+ title: "share link copied",
113
+ });
94
114
  expect(controller.getSnapshot().status).not.toContain("ssh p_abc123xy@cueloop.dev");
95
115
  });
96
116
  });
@@ -102,7 +122,9 @@ describe("pullShared", () => {
102
122
  annotations: [annotation("a1", "SHA256:mate")],
103
123
  participants: [{ id: "SHA256:mate", provider: "ssh", name: "Sam" }],
104
124
  });
105
- const { controller, client } = await connectedController(sessionFixture({ shareId: "p_abc123xy" }));
125
+ const { controller, client } = await connectedController(
126
+ sessionFixture({ shareId: "p_abc123xy" }),
127
+ );
106
128
 
107
129
  // Act
108
130
  controller.pullShared();
@@ -135,7 +157,9 @@ describe("mirror on annotate", () => {
135
157
  test("pushes an edited note up when the plan is shared", async () => {
136
158
  // Arrange
137
159
  pushShare.mockClear();
138
- const { controller } = await connectedController(sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:me")] }));
160
+ const { controller } = await connectedController(
161
+ sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:me")] }),
162
+ );
139
163
 
140
164
  // Act
141
165
  controller.updateAnnotation("a1", "revised body");
@@ -144,13 +168,22 @@ describe("mirror on annotate", () => {
144
168
  // Assert
145
169
  expect(pushShare).toHaveBeenCalledTimes(1);
146
170
  expect(pushShare.mock.calls[0]?.[0]).toBe("p_abc123xy");
147
- expect(pushShare.mock.calls[0]?.[1]).toEqual([{ id: "a1", kind: "comment", anchor: { quote: "Plan", prefix: "", suffix: "" }, body: "revised body" }]);
171
+ expect(pushShare.mock.calls[0]?.[1]).toEqual([
172
+ {
173
+ id: "a1",
174
+ kind: "comment",
175
+ anchor: { quote: "Plan", prefix: "", suffix: "" },
176
+ body: "revised body",
177
+ },
178
+ ]);
148
179
  });
149
180
 
150
181
  test("does not push when the plan was never shared", async () => {
151
182
  // Arrange
152
183
  pushShare.mockClear();
153
- const { controller } = await connectedController(sessionFixture({ annotations: [annotation("a1", "SHA256:me")] }));
184
+ const { controller } = await connectedController(
185
+ sessionFixture({ annotations: [annotation("a1", "SHA256:me")] }),
186
+ );
154
187
 
155
188
  // Act
156
189
  controller.updateAnnotation("a1", "revised body");
@@ -163,7 +196,9 @@ describe("mirror on annotate", () => {
163
196
  test("does not push when the local write is rejected", async () => {
164
197
  // Arrange - the daemon write fails (e.g. a resolved session)
165
198
  pushShare.mockClear();
166
- const { controller, client } = await connectedController(sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:me")] }));
199
+ const { controller, client } = await connectedController(
200
+ sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:me")] }),
201
+ );
167
202
  (client.sessionAnnotate as ReturnType<typeof mock>).mockImplementationOnce(async () => {
168
203
  throw new Error("session is resolved");
169
204
  });
@@ -183,7 +218,10 @@ describe("startSharePoll", () => {
183
218
  pullShare.mockClear();
184
219
  remote = sessionFixture({ annotations: [] });
185
220
  const clock = new ManualClock();
186
- const { controller } = await connectedController(sessionFixture({ shareId: "p_abc123xy" }), clock);
221
+ const { controller } = await connectedController(
222
+ sessionFixture({ shareId: "p_abc123xy" }),
223
+ clock,
224
+ );
187
225
 
188
226
  // Act - immediate pull
189
227
  const stop = controller.startSharePoll();
@@ -213,9 +251,14 @@ describe("startSharePoll", () => {
213
251
  pullShare.mockClear();
214
252
  remote = sessionFixture({ annotations: [] });
215
253
  let release: () => void = () => {};
216
- pullShare.mockImplementationOnce(() => new Promise<ReviewSession>((resolve) => (release = () => resolve(remote))));
254
+ pullShare.mockImplementationOnce(
255
+ () => new Promise<ReviewSession>((resolve) => (release = () => resolve(remote))),
256
+ );
217
257
  const clock = new ManualClock();
218
- const { controller } = await connectedController(sessionFixture({ shareId: "p_abc123xy" }), clock);
258
+ const { controller } = await connectedController(
259
+ sessionFixture({ shareId: "p_abc123xy" }),
260
+ clock,
261
+ );
219
262
 
220
263
  // Act - start (pull is in flight), leave, then let the pull settle
221
264
  const stop = controller.startSharePoll();
@@ -236,9 +279,14 @@ describe("startSharePoll", () => {
236
279
  pullShare.mockClear();
237
280
  remote = sessionFixture({ annotations: [] });
238
281
  let release: () => void = () => {};
239
- pullShare.mockImplementationOnce(() => new Promise<ReviewSession>((resolve) => (release = () => resolve(remote))));
282
+ pullShare.mockImplementationOnce(
283
+ () => new Promise<ReviewSession>((resolve) => (release = () => resolve(remote))),
284
+ );
240
285
  const clock = new ManualClock();
241
- const { controller } = await connectedController(sessionFixture({ shareId: "p_abc123xy" }), clock);
286
+ const { controller } = await connectedController(
287
+ sessionFixture({ shareId: "p_abc123xy" }),
288
+ clock,
289
+ );
242
290
 
243
291
  // Act - run A starts (pull hangs), then run B restarts while A is in flight
244
292
  controller.startSharePoll();
@@ -24,13 +24,36 @@ import {
24
24
  type VerdictKind,
25
25
  } from "@cueloop/schema";
26
26
  import { loadBundledExporters, type BundledExporter } from "./integrations";
27
- import { collaboratorAnnotations, publishShare, pullShare, pushShare, shareIdFromLine } from "./share";
27
+ import {
28
+ collaboratorAnnotations,
29
+ publishShare,
30
+ pullShare,
31
+ pushShare,
32
+ shareIdFromLine,
33
+ } from "./share";
28
34
  import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view-plan";
29
35
  import { diffRowAnchor, diffRows, type DiffRow } from "./view-diff";
36
+ import {
37
+ changeRejectionForRow,
38
+ curateDiff,
39
+ hunkRejectionForRow,
40
+ isRowRejected,
41
+ parseFileDiff,
42
+ rejectsWholeHunk,
43
+ sameRejection,
44
+ type HunkRejection,
45
+ } from "./diff-hunk-curate";
46
+ import type { FileDiffMetadata } from "@pierre/diffs";
30
47
  import { firstUnviewedIndex, walkFiles, type WalkFile } from "./walk";
31
48
  import { editInEditor } from "./editor";
32
49
  import { focusHerdrPane } from "./herdr";
33
- import { persistAutoClose, persistReviewState, persistReviewWidth, type AutoClose, type CueloopConfig } from "./config";
50
+ import {
51
+ persistAutoClose,
52
+ persistReviewState,
53
+ persistReviewWidth,
54
+ type AutoClose,
55
+ type CueloopConfig,
56
+ } from "./config";
34
57
  import type { ReviewPanelMode } from "./review-panel";
35
58
 
36
59
  /**
@@ -50,11 +73,44 @@ export const DEFAULT_AUTO_CLOSE = 5;
50
73
  /** How often an open shared plan re-pulls collaborator notes (ADR 0005 stage 2). */
51
74
  export const SHARE_POLL_MS = 4000;
52
75
 
76
+ /** Shared empty set so "nothing rejected" is a stable identity for renders. */
77
+ const EMPTY_REJECTED_ROWS: Set<number> = new Set();
78
+
79
+ /** Shared empty list so "nothing curated out" is a stable identity for renders. */
80
+ const EMPTY_CURATION_ITEMS: CurationItem[] = [];
81
+
82
+ /** Status shown when a curated-out removal (diff rejection or plan cut) is restored. */
83
+ const REMOVAL_RESTORED_STATUS = "removal restored";
84
+
53
85
  export interface ToastState {
54
86
  title?: string;
55
87
  body: string;
56
88
  }
57
89
 
90
+ /**
91
+ * One removed piece projected for the review rail, unified across both review
92
+ * kinds: a diff hunk/change the owner curated out, or a plan block they cut.
93
+ * `source` tells restore and reveal which path to take; `preview` is the removed
94
+ * content (a few lines) the card renders struck through; `revealIndex` is the
95
+ * diff row (diff) or display block (plan) to scroll to and restore against.
96
+ */
97
+ export interface CurationItem {
98
+ id: string;
99
+ source: "diff" | "plan";
100
+ preview: string[];
101
+ revealIndex: number;
102
+ }
103
+
104
+ /** Stable id for a rejection's rail item: diff:path#hunk#change (or #hunk for a whole hunk). */
105
+ export function curationItemId(rejection: HunkRejection): string {
106
+ return `diff:${rejection.path}#${rejection.hunkIndex}#${rejection.changeIndex ?? "hunk"}`;
107
+ }
108
+
109
+ /** Stable id for a plan cut's rail item, keyed on its fixed base-content line range. */
110
+ function planCutId(base: { lineStart: number; lineEnd: number }): string {
111
+ return `plan:${base.lineStart}-${base.lineEnd}`;
112
+ }
113
+
58
114
  export interface ControllerSnapshot {
59
115
  session: ReviewSession | null;
60
116
  inbox: ReviewSession[] | null;
@@ -118,13 +174,29 @@ export interface ReviewController {
118
174
  setSelfName(name: string): void;
119
175
  /** Cut the block under the cursor, or restore a cut one. */
120
176
  cut(displayIndex: number): void;
177
+ /** Toggle rejection of the whole hunk under the diff cursor (owner curation). */
178
+ toggleRejectHunk(rowIndex: number): void;
179
+ /** Toggle rejection of the single change under the diff cursor (owner curation). */
180
+ toggleRejectChange(rowIndex: number): void;
181
+ /** Rendered row indices dropped by the current reject decisions (for dimming). */
182
+ rejectedRows(): Set<number>;
183
+ /** The curated-out rejections as rail items, in the order they were rejected. */
184
+ curationItems(): CurationItem[];
185
+ /** Undo one curation item by id: drop the rejection and recompute the working copy. */
186
+ restoreCuration(id: string): void;
121
187
  /** The $EDITOR hand-off on the working copy. */
122
188
  edit(): void;
123
189
  /**
124
190
  * Anchor and store an annotation; both plan and diff anchor constructions.
125
191
  * Returns the minted annotation id so the view can select the new card.
126
192
  */
127
- annotate(kind: "comment" | "suggestion", displayIndex: number, start: number, end: number, body: string): string | undefined;
193
+ annotate(
194
+ kind: "comment",
195
+ displayIndex: number,
196
+ start: number,
197
+ end: number,
198
+ body: string,
199
+ ): string | undefined;
128
200
  /** Rewrite a stored annotation's body in place (the rail-card edit). */
129
201
  updateAnnotation(id: string, body: string): void;
130
202
  removeAnnotation(id: string): void;
@@ -185,11 +257,20 @@ class Controller implements ReviewController {
185
257
  private shareRun: object | null = null;
186
258
  /** Projections keyed by session identity so renders reuse one computation. */
187
259
  private derivedFor: ReviewSession | null = null;
188
- private derived: { display: DisplayBlock[]; rows: DiffRow[]; files: WalkFile[] } = {
260
+ private derived: {
261
+ display: DisplayBlock[];
262
+ rows: DiffRow[];
263
+ files: WalkFile[];
264
+ /** Per-path parsed diff models, for hunk curation; empty when files absent. */
265
+ models: Map<string, FileDiffMetadata>;
266
+ } = {
189
267
  display: [],
190
268
  rows: [],
191
269
  files: [],
270
+ models: new Map(),
192
271
  };
272
+ /** The owner's per-hunk/change reject decisions for the open diff session. */
273
+ private rejections: HunkRejection[] = [];
193
274
 
194
275
  constructor(private readonly options: ReviewControllerOptions) {
195
276
  this.readOnly = options.readOnly ?? false;
@@ -211,7 +292,9 @@ class Controller implements ReviewController {
211
292
  connect(): void {
212
293
  void (async () => {
213
294
  try {
214
- const openClient = this.options.openClient ?? (() => DaemonClient.connect({ home: this.options.home, autostart: true }));
295
+ const openClient =
296
+ this.options.openClient ??
297
+ (() => DaemonClient.connect({ home: this.options.home, autostart: true }));
215
298
  const client = await openClient();
216
299
  if (this.closed) return void client.close();
217
300
  this.client = client;
@@ -270,11 +353,15 @@ class Controller implements ReviewController {
270
353
  const session = this.snapshot.session;
271
354
  if (this.derivedFor === session) return;
272
355
  this.derivedFor = session;
273
- const rows = session && session.artifact.type === "diff" ? diffRows(session.artifact.content) : [];
356
+ const rows =
357
+ session && session.artifact.type === "diff" ? diffRows(session.artifact.content) : [];
358
+ const models = new Map<string, FileDiffMetadata>();
359
+ for (const file of session?.artifact.files ?? []) models.set(file.path, parseFileDiff(file));
274
360
  this.derived = {
275
361
  display: session ? buildDisplay(session.artifact.content, session.workingCopy) : [],
276
362
  rows,
277
363
  files: walkFiles(rows),
364
+ models,
278
365
  };
279
366
  }
280
367
 
@@ -319,14 +406,17 @@ class Controller implements ReviewController {
319
406
 
320
407
  /** Optimistic apply: the daemon response is the next session snapshot. */
321
408
  private apply(mutation: Promise<ReviewSession>): void {
322
- mutation.then((session) => this.update({ session })).catch((error: unknown) =>
323
- this.setStatus(String(error instanceof Error ? error.message : error)),
324
- );
409
+ mutation
410
+ .then((session) => this.update({ session }))
411
+ .catch((error: unknown) =>
412
+ this.setStatus(String(error instanceof Error ? error.message : error)),
413
+ );
325
414
  }
326
415
 
327
416
  // ── verbs ───────────────────────────────────
328
417
  open(id: string): void {
329
418
  this.locallyViewed.clear();
419
+ this.rejections = [];
330
420
  const cached = this.snapshot.inbox?.find((candidate) => candidate.id === id);
331
421
  if (cached) this.update({ session: cached });
332
422
  else void this.refreshSession(id);
@@ -336,7 +426,9 @@ class Controller implements ReviewController {
336
426
  this.client
337
427
  ?.sessionDelete(id)
338
428
  .then(() => this.setStatus("plan deleted"))
339
- .catch((error: unknown) => this.setStatus(`delete failed: ${error instanceof Error ? error.message : String(error)}`));
429
+ .catch((error: unknown) =>
430
+ this.setStatus(`delete failed: ${error instanceof Error ? error.message : String(error)}`),
431
+ );
340
432
  }
341
433
 
342
434
  setSelfName(name: string): void {
@@ -352,18 +444,185 @@ class Controller implements ReviewController {
352
444
  if (!block) return;
353
445
  const working = this.working();
354
446
  if (block.type === "del") {
355
- const line = restoreLine(nextWorkBlock(this.display(), displayIndex), working.split("\n").length);
356
- // restoreBlock returns undefined when the block structure round-trips
357
- // to the submitted revision - the working copy is gone
358
- const restored = restoreBlock(session.artifact.content, working, block.base!, line);
359
- this.setWorkingCopy(restored);
360
- this.setStatus("cut restored");
447
+ this.restoreDelBlock(block, displayIndex);
361
448
  } else if (block.work) {
362
449
  this.setWorkingCopy(cutBlock(working, block.work));
363
450
  this.setStatus("block cut - it serializes into the diff");
364
451
  }
365
452
  }
366
453
 
454
+ /** Re-insert a cut plan block at the next surviving block's line (Cut toggle + rail undo). */
455
+ private restoreDelBlock(block: DisplayBlock, displayIndex: number): void {
456
+ const session = this.snapshot.session;
457
+ if (!session || !block.base) return;
458
+ const working = this.working();
459
+ const line = restoreLine(
460
+ nextWorkBlock(this.display(), displayIndex),
461
+ working.split("\n").length,
462
+ );
463
+ // restoreBlock returns undefined when the block structure round-trips to the
464
+ // submitted revision - the working copy is back to pristine and is dropped
465
+ const restored = restoreBlock(session.artifact.content, working, block.base, line);
466
+ this.setWorkingCopy(restored);
467
+ this.setStatus(REMOVAL_RESTORED_STATUS);
468
+ }
469
+
470
+ // ── diff hunk curation ──────────────────────
471
+ /** The parsed model and row for a curation action, or null with a status set. */
472
+ private curationRow(rowIndex: number): { row: DiffRow; model: FileDiffMetadata } | null {
473
+ const session = this.snapshot.session;
474
+ if (!session || session.status === "resolved") return null;
475
+ if (!session.artifact.files) {
476
+ this.setStatus("hunk curation needs full file contents (PR diffs cannot be curated)");
477
+ return null;
478
+ }
479
+ const row = this.rows()[rowIndex];
480
+ if (!row || row.kind === "file" || row.kind === "hunk") {
481
+ this.setStatus("move to a code line to curate");
482
+ return null;
483
+ }
484
+ const model = this.derived.models.get(row.file);
485
+ if (!model) return null;
486
+ return { row, model };
487
+ }
488
+
489
+ toggleRejectHunk(rowIndex: number): void {
490
+ const located = this.curationRow(rowIndex);
491
+ if (!located) return;
492
+ const target = hunkRejectionForRow(located.row.file, located.model, located.row);
493
+ if (!target) return this.setStatus("no hunk under the cursor");
494
+ const wholeHunk = (rejection: HunkRejection): boolean => rejectsWholeHunk(rejection, target);
495
+ if (this.rejections.some(wholeHunk)) {
496
+ this.rejections = this.rejections.filter((rejection) => !wholeHunk(rejection));
497
+ this.setStatus("hunk restored");
498
+ } else {
499
+ // a whole-hunk reject supersedes any change-level rejects inside it
500
+ this.rejections = this.rejections.filter(
501
+ (rejection) =>
502
+ !(rejection.path === target.path && rejection.hunkIndex === target.hunkIndex),
503
+ );
504
+ this.rejections.push(target);
505
+ this.setStatus("hunk rejected - dropped from the working copy");
506
+ }
507
+ this.recomputeCuration();
508
+ }
509
+
510
+ toggleRejectChange(rowIndex: number): void {
511
+ const located = this.curationRow(rowIndex);
512
+ if (!located) return;
513
+ if (located.row.kind !== "add" && located.row.kind !== "del")
514
+ return this.setStatus("move to a changed line to reject a change");
515
+ const target = changeRejectionForRow(located.row.file, located.model, located.row);
516
+ if (!target) return this.setStatus("no change under the cursor");
517
+ const wholeCovers = this.rejections.some((rejection) => rejectsWholeHunk(rejection, target));
518
+ if (wholeCovers) return this.setStatus("the whole hunk is rejected - restore it first");
519
+ if (this.rejections.some((rejection) => sameRejection(rejection, target))) {
520
+ this.rejections = this.rejections.filter((rejection) => !sameRejection(rejection, target));
521
+ this.setStatus("change restored");
522
+ } else {
523
+ this.rejections.push(target);
524
+ this.setStatus("change rejected - dropped from the working copy");
525
+ }
526
+ this.recomputeCuration();
527
+ }
528
+
529
+ /** Recompute the curated patch and push it as the working copy (or clear it). */
530
+ private recomputeCuration(): void {
531
+ const files = this.snapshot.session?.artifact.files;
532
+ if (!files) return;
533
+ // no decisions left = the working copy reverts to the full submitted diff
534
+ this.setWorkingCopy(this.rejections.length ? curateDiff(files, this.rejections) : undefined);
535
+ // dimming reads `rejections` directly; a bare update forces the re-read
536
+ this.update({});
537
+ }
538
+
539
+ rejectedRows(): Set<number> {
540
+ this.ensureDerived();
541
+ if (!this.rejections.length) return EMPTY_REJECTED_ROWS;
542
+ const rejected = new Set<number>();
543
+ this.derived.rows.forEach((row, index) => {
544
+ const model = this.derived.models.get(row.file);
545
+ if (model && isRowRejected(row.file, model, row, this.rejections)) rejected.add(index);
546
+ });
547
+ return rejected;
548
+ }
549
+
550
+ curationItems(): CurationItem[] {
551
+ this.ensureDerived();
552
+ const session = this.snapshot.session;
553
+ if (!session) return EMPTY_CURATION_ITEMS;
554
+ return session.artifact.type === "diff" ? this.diffCurationItems() : this.planCurationItems();
555
+ }
556
+
557
+ restoreCuration(id: string): void {
558
+ // diff rejections drop from the reject list; a plan cut re-inserts its block
559
+ if (id.startsWith("diff:")) {
560
+ const kept = this.rejections.filter((rejection) => curationItemId(rejection) !== id);
561
+ if (kept.length === this.rejections.length) return;
562
+ this.rejections = kept;
563
+ this.setStatus(REMOVAL_RESTORED_STATUS);
564
+ this.recomputeCuration();
565
+ return;
566
+ }
567
+ const display = this.display();
568
+ const displayIndex = display.findIndex(
569
+ (block) => block.type === "del" && block.base && planCutId(block.base) === id,
570
+ );
571
+ if (displayIndex !== -1) this.restoreDelBlock(display[displayIndex]!, displayIndex);
572
+ }
573
+
574
+ /** The curated-out diff rejections as removal cards (newest decisions last). */
575
+ private diffCurationItems(): CurationItem[] {
576
+ if (!this.rejections.length) return EMPTY_CURATION_ITEMS;
577
+ const items: CurationItem[] = [];
578
+ for (const rejection of this.rejections) {
579
+ const rows = this.rowsForRejection(rejection);
580
+ const firstRow = rows[0];
581
+ items.push({
582
+ id: curationItemId(rejection),
583
+ source: "diff",
584
+ preview: rows.map(
585
+ (row) => `${row.kind === "add" ? "+" : "-"} ${row.text.replace(/\n$/, "")}`,
586
+ ),
587
+ revealIndex: firstRow ? this.derived.rows.indexOf(firstRow) : 0,
588
+ });
589
+ }
590
+ return items;
591
+ }
592
+
593
+ /** The cut plan blocks as removal cards, in document order. */
594
+ private planCurationItems(): CurationItem[] {
595
+ const items: CurationItem[] = [];
596
+ this.derived.display.forEach((block, displayIndex) => {
597
+ if (block.type !== "del" || !block.base) return;
598
+ items.push({
599
+ id: planCutId(block.base),
600
+ source: "plan",
601
+ preview: block.base.text.split("\n"),
602
+ revealIndex: displayIndex,
603
+ });
604
+ });
605
+ return items.length ? items : EMPTY_CURATION_ITEMS;
606
+ }
607
+
608
+ /** The change/deletion rows a rejection covers, for its preview and reveal row. */
609
+ private rowsForRejection(rejection: HunkRejection): DiffRow[] {
610
+ const model = this.derived.models.get(rejection.path);
611
+ if (!model) return [];
612
+ const rows: DiffRow[] = [];
613
+ for (const row of this.derived.rows) {
614
+ if (row.file !== rejection.path || (row.kind !== "add" && row.kind !== "del")) continue;
615
+ if (rejection.changeIndex === undefined) {
616
+ const target = hunkRejectionForRow(rejection.path, model, row);
617
+ if (target && target.hunkIndex === rejection.hunkIndex) rows.push(row);
618
+ } else {
619
+ const target = changeRejectionForRow(rejection.path, model, row);
620
+ if (target && sameRejection(target, rejection)) rows.push(row);
621
+ }
622
+ }
623
+ return rows;
624
+ }
625
+
367
626
  edit(): void {
368
627
  const session = this.snapshot.session;
369
628
  if (!session || session.status === "resolved") return;
@@ -394,7 +653,7 @@ class Controller implements ReviewController {
394
653
  }
395
654
 
396
655
  annotate(
397
- kind: "comment" | "suggestion",
656
+ kind: "comment",
398
657
  displayIndex: number,
399
658
  start: number,
400
659
  end: number,
@@ -408,14 +667,15 @@ class Controller implements ReviewController {
408
667
  } else {
409
668
  const display = this.display();
410
669
  const workBlocks = display.filter((entry) => entry.work).map((entry) => entry.work!);
411
- const workBlockIndex = display.slice(0, displayIndex + 1).filter((entry) => entry.work).length - 1;
670
+ const workBlockIndex =
671
+ display.slice(0, displayIndex + 1).filter((entry) => entry.work).length - 1;
412
672
  anchor = makeAnchor(workBlocks, workBlockIndex, start, end);
413
673
  }
414
674
  const wire = { id: newAnnotationId(), kind, anchor, body };
415
675
  const persisted = this.client!.sessionAnnotate(session.id, wire);
416
676
  this.apply(persisted);
417
677
  this.mirrorAnnotation(persisted, wire);
418
- this.setStatus(kind === "suggestion" ? "suggestion added - the agent applies it" : "comment added");
678
+ this.setStatus("comment added");
419
679
  return wire.id;
420
680
  }
421
681
 
@@ -433,7 +693,10 @@ class Controller implements ReviewController {
433
693
  }
434
694
 
435
695
  // push only after the local write lands, so a rejected write never leaks to the share
436
- private mirrorAnnotation(persisted: Promise<ReviewSession>, annotation: Omit<Annotation, "createdAt">): void {
696
+ private mirrorAnnotation(
697
+ persisted: Promise<ReviewSession>,
698
+ annotation: Omit<Annotation, "createdAt">,
699
+ ): void {
437
700
  const shareId = this.snapshot.session?.shareId;
438
701
  if (!shareId) return;
439
702
  void persisted.then(() => pushShare(shareId, [annotation])).catch(() => {});
@@ -509,7 +772,11 @@ class Controller implements ReviewController {
509
772
  for (const exporter of this.exporters) {
510
773
  if (!exporter.runsOn(verdict)) continue;
511
774
  void exporter.run(resolved).then((exportResult) => {
512
- this.setStatus(exportResult.success && exportResult.path ? `exported to ${exportResult.path}` : `export failed: ${exportResult.error ?? "unknown"}`);
775
+ this.setStatus(
776
+ exportResult.success && exportResult.path
777
+ ? `exported to ${exportResult.path}`
778
+ : `export failed: ${exportResult.error ?? "unknown"}`,
779
+ );
513
780
  });
514
781
  }
515
782
  // Hand the reviewer back to the agent. The default is a visible
@@ -520,7 +787,9 @@ class Controller implements ReviewController {
520
787
  else if (typeof delay === "number") this.startCounting(delay);
521
788
  else this.startCounting(DEFAULT_AUTO_CLOSE);
522
789
  })
523
- .catch((error: unknown) => this.setStatus(String(error instanceof Error ? error.message : error)));
790
+ .catch((error: unknown) =>
791
+ this.setStatus(String(error instanceof Error ? error.message : error)),
792
+ );
524
793
  }
525
794
 
526
795
  share(): void {
@@ -532,10 +801,12 @@ class Controller implements ReviewController {
532
801
  // Stamp the id back so a later pull knows which share to collect from.
533
802
  const shareId = shareIdFromLine(line);
534
803
  if (shareId && this.client) await this.client.sessionSetShareId(session.id, shareId);
535
- this.setStatus(copied ? "✓ share link copied" : "share link ready");
804
+ this.setStatus("");
536
805
  this.showToast(line, copied ? "share link copied" : "share link");
537
806
  })
538
- .catch((error: unknown) => this.setStatus(`share failed: ${error instanceof Error ? error.message : String(error)}`));
807
+ .catch((error: unknown) =>
808
+ this.setStatus(`share failed: ${error instanceof Error ? error.message : String(error)}`),
809
+ );
539
810
  }
540
811
 
541
812
  /**
@@ -550,7 +821,10 @@ class Controller implements ReviewController {
550
821
  const client = this.client;
551
822
  return pullShare(session.shareId)
552
823
  .then((remote) =>
553
- client.sessionMergeShared(session.id, { annotations: collaboratorAnnotations(remote), participants: remote.participants }),
824
+ client.sessionMergeShared(session.id, {
825
+ annotations: collaboratorAnnotations(remote),
826
+ participants: remote.participants,
827
+ }),
554
828
  )
555
829
  .then(() => {})
556
830
  .catch(() => {});
@@ -568,7 +842,8 @@ class Controller implements ReviewController {
568
842
  this.shareRun = run;
569
843
  const tick = (): void => {
570
844
  void this.pullShared().finally(() => {
571
- if (this.shareRun === run && !this.closed) this.sharePoll = this.clock.setTimeout(tick, SHARE_POLL_MS);
845
+ if (this.shareRun === run && !this.closed)
846
+ this.sharePoll = this.clock.setTimeout(tick, SHARE_POLL_MS);
572
847
  });
573
848
  };
574
849
  tick();