@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
package/src/keymap.ts CHANGED
@@ -19,12 +19,20 @@ export type Intent =
19
19
  | { type: "confirmDialog" }
20
20
  | { type: "startSpan" }
21
21
  | { type: "spanKey"; name: string }
22
- | { type: "openCompose"; kind: "comment" | "suggestion"; from: "cursor" | "span" }
22
+ | { type: "spanCut" }
23
+ | { type: "openSpanActions" }
24
+ | { type: "moveSpanAction"; direction: -1 | 1 }
25
+ | { type: "pickSpanAction"; index?: number }
26
+ | { type: "closeSpanActions" }
27
+ | { type: "openCompose"; kind: "comment"; from: "cursor" | "span" }
23
28
  | { type: "openSubmit" }
24
29
  | { type: "share" }
25
30
  | { type: "cut" }
26
31
  | { type: "edit" }
27
32
  | { type: "editCard" }
33
+ | { type: "rejectHunk" }
34
+ | { type: "rejectChange" }
35
+ | { type: "restoreCuration" }
28
36
  | { type: "nextAnnotation" }
29
37
  | { type: "prevAnnotation" }
30
38
  | { type: "walkStart" }
@@ -64,7 +72,16 @@ export interface KeyState {
64
72
  /** Owner-only: publish the plan as a share. A collaborator never re-shares. */
65
73
  canShare?: boolean;
66
74
  /** Layer that owns keys before the grammar runs. */
67
- overlay: "none" | "walk" | "compose" | "submit" | "confirm" | "prompt" | "completion-prompt" | "completion-counting";
75
+ overlay:
76
+ | "none"
77
+ | "walk"
78
+ | "compose"
79
+ | "submit"
80
+ | "confirm"
81
+ | "prompt"
82
+ | "spanActions"
83
+ | "completion-prompt"
84
+ | "completion-counting";
68
85
  view: "inbox" | "plan" | "diff";
69
86
  /** Plan-only span selection sub-mode. */
70
87
  spanMode: boolean;
@@ -79,9 +96,19 @@ export interface KeyState {
79
96
  }
80
97
 
81
98
  /** Verbs that write session state; an observer never reaches their handlers. */
82
- const MUTATING_ACTIONS = new Set(["comment", "suggest", "cut", "edit", "delete_annotation", "submit", "walk", "share"]);
99
+ const MUTATING_ACTIONS = new Set([
100
+ "comment",
101
+ "cut",
102
+ "edit",
103
+ "delete_annotation",
104
+ "submit",
105
+ "walk",
106
+ "share",
107
+ "reject_hunk",
108
+ "restore_curation",
109
+ ]);
83
110
 
84
- const SPAN_KEYS = ["l", "h", "w", "b", "$", "0"];
111
+ const SPAN_KEYS = new Set(["l", "h", "w", "b", "$", "0"]);
85
112
 
86
113
  function status(message: string): Intent[] {
87
114
  return [{ type: "status", message }];
@@ -126,6 +153,15 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
126
153
  if (name === "escape") return [{ type: "closeOverlay" }];
127
154
  return [];
128
155
  }
156
+ // the quick-actions list owns its keys: j/k move, ⏎ picks the highlighted
157
+ // action (inserting its preset comment), escape returns to the span toolbar
158
+ if (state.overlay === "spanActions") {
159
+ if (name === "j" || name === "down") return [{ type: "moveSpanAction", direction: 1 }];
160
+ if (name === "k" || name === "up") return [{ type: "moveSpanAction", direction: -1 }];
161
+ if (name === "return" || name === "enter") return [{ type: "pickSpanAction" }];
162
+ if (name === "escape") return [{ type: "closeSpanActions" }];
163
+ return [];
164
+ }
129
165
  if (state.overlay === "completion-prompt" || state.overlay === "completion-counting") {
130
166
  if (name === "return" || name === "enter" || name === "q") return [{ type: "finishReview" }];
131
167
  if (name === "a") return [{ type: "optInAutoClose" }];
@@ -148,8 +184,10 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
148
184
  const action = resolvedAction ?? actionFor(state.keys, name, key.shift);
149
185
  if (action === "quit") return [{ type: "exit" }];
150
186
  // the ONE read-only rule: any mutating attempt answers instead of acting
151
- // (span-mode c/s are hardwired keys, so they gate by name as well)
152
- const mutating = MUTATING_ACTIONS.has(action ?? "") || (state.spanMode && (name === "c" || name === "s"));
187
+ // (span-mode c, x, and a are hardwired keys, so they gate by name as well)
188
+ const mutating =
189
+ MUTATING_ACTIONS.has(action ?? "") ||
190
+ (state.spanMode && (name === "c" || name === "x" || name === "a"));
153
191
  if (state.readOnly && mutating) return status("observer - read-only");
154
192
 
155
193
  // share is a session-level verb: it works from any view, owner only
@@ -161,7 +199,7 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
161
199
  if (state.view === "inbox") return inboxGrammar(state, name);
162
200
  // span mode owns its single-letter keys (b slides the span back) before the
163
201
  // review-panel controls claim them
164
- if (state.spanMode) return spanGrammar(name);
202
+ if (state.spanMode) return spanGrammar(state, name);
165
203
  // the review panel rides both plan and diff reviews; collapsing and resizing
166
204
  // are view state, so the read-only gate above lets them through
167
205
  const reviewPanel = reviewPanelGrammar(action);
@@ -200,19 +238,40 @@ function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
200
238
  if (!state.cursorAnnotatable) return status("move to a code line to comment");
201
239
  return [{ type: "openCompose", kind: "comment", from: "cursor" }];
202
240
  }
241
+ // cut rejects the change under the cursor, reject_hunk the whole hunk; both
242
+ // write the working copy, so they gate on owner like a plan edit.
243
+ if (action === "cut" || action === "reject_hunk") {
244
+ if (state.resolved) return status("review submitted - read-only");
245
+ if (state.canEditPlan === false) return status("only the diff owner can curate hunks");
246
+ return [{ type: action === "reject_hunk" ? "rejectHunk" : "rejectChange" }];
247
+ }
248
+ // restore un-does a curated-out rejection from the rail; same owner gate as reject
249
+ if (action === "restore_curation") {
250
+ if (state.resolved) return status("review submitted - read-only");
251
+ if (state.canEditPlan === false) return status("only the diff owner can curate hunks");
252
+ return [{ type: "restoreCuration" }];
253
+ }
203
254
  const shared = annotationCluster(state, action);
204
255
  if (shared) return shared;
205
- if (action === "span" || action === "cut" || action === "edit" || action === "suggest") {
256
+ if (action === "span" || action === "edit") {
206
257
  return status("plan-only verb - diff review uses c on a line");
207
258
  }
208
259
  return [];
209
260
  }
210
261
 
211
- function spanGrammar(name: string): Intent[] {
262
+ function spanGrammar(state: KeyState, name: string): Intent[] {
212
263
  if (name === "escape") return [{ type: "closeOverlay" }];
213
- if (SPAN_KEYS.includes(name)) return [{ type: "spanKey", name }];
214
- if (name === "c" || name === "s") {
215
- return [{ type: "openCompose", kind: name === "s" ? "suggestion" : "comment", from: "span" }];
264
+ if (SPAN_KEYS.has(name)) return [{ type: "spanKey", name }];
265
+ // c comments, x cuts, a opens quick-actions - all mutate, so a resolved
266
+ // review is read-only, like the plan grammar
267
+ if (name === "c" || name === "x" || name === "a") {
268
+ if (state.resolved) return status("review submitted - read-only");
269
+ if (name === "c") return [{ type: "openCompose", kind: "comment", from: "span" }];
270
+ if (name === "a") return [{ type: "openSpanActions" }];
271
+ // partial-span cut is not in the working-copy model, so cut removes the
272
+ // whole block the span sits in; owner-only, like plan cut
273
+ if (state.canEditPlan === false) return [];
274
+ return [{ type: "spanCut" }];
216
275
  }
217
276
  return [];
218
277
  }
@@ -223,16 +282,23 @@ function planGrammar(state: KeyState, action: string | undefined, name: string):
223
282
  if (action === "walk") return status("the guided walk is a diff-review mode");
224
283
  if (name === "escape") return [{ type: "deselect" }];
225
284
  if (action === "span") return state.cursorAnnotatable ? [{ type: "startSpan" }] : [];
226
- if (action === "comment" || action === "suggest") {
285
+ if (action === "comment") {
227
286
  if (state.resolved) return status("review submitted - read-only");
228
287
  if (!state.cursorAnnotatable) return status("text is cut - restore it first");
229
- return [{ type: "openCompose", kind: action === "suggest" ? "suggestion" : "comment", from: "cursor" }];
288
+ return [{ type: "openCompose", kind: "comment", from: "cursor" }];
289
+ }
290
+ // restore un-does a rail removal (a cut block); a plan edit, so owner-only
291
+ if (action === "restore_curation") {
292
+ if (state.resolved) return status("review submitted - read-only");
293
+ if (state.canEditPlan === false) return [];
294
+ return [{ type: "restoreCuration" }];
230
295
  }
231
296
  if (action === "cut" || action === "edit") {
232
297
  if (state.resolved) return status("review submitted - read-only");
233
298
  // the document selects, the rail edits: with a card selected, Cut deletes
234
299
  // the annotation and edit rewrites the card body in place
235
- if (state.hasFocusedAnnotation) return [action === "cut" ? { type: "removeAnnotation" } : { type: "editCard" }];
300
+ if (state.hasFocusedAnnotation)
301
+ return [action === "cut" ? { type: "removeAnnotation" } : { type: "editCard" }];
236
302
  // editing the plan itself is the owner's verb; a share viewer only annotates,
237
303
  // and has no edit affordance, so the key is silent rather than a nag
238
304
  if (state.canEditPlan === false) return [];
@@ -265,7 +331,8 @@ function annotationCluster(state: KeyState, action: string | undefined): Intent[
265
331
  }
266
332
  if (action === "submit") {
267
333
  // a collaborator's notes union back as they go; there is no verdict to submit
268
- if (state.canSubmitVerdict === false) return status("shared view - your notes save as you go; q to leave");
334
+ if (state.canSubmitVerdict === false)
335
+ return status("shared view - your notes save as you go; q to leave");
269
336
  return state.resolved ? [] : [{ type: "openSubmit" }];
270
337
  }
271
338
  return null;
@@ -68,7 +68,9 @@ describe("widthFromMouseColumn", () => {
68
68
  });
69
69
 
70
70
  test("the resize step widens and narrows within the clamp", () => {
71
- expect(clampWidth(REVIEW_DEFAULT_WIDTH + REVIEW_RESIZE_STEP)).toBe(REVIEW_DEFAULT_WIDTH + REVIEW_RESIZE_STEP);
71
+ expect(clampWidth(REVIEW_DEFAULT_WIDTH + REVIEW_RESIZE_STEP)).toBe(
72
+ REVIEW_DEFAULT_WIDTH + REVIEW_RESIZE_STEP,
73
+ );
72
74
  expect(clampWidth(REVIEW_MIN_WIDTH - REVIEW_RESIZE_STEP)).toBe(REVIEW_MIN_WIDTH);
73
75
  });
74
76
  });
@@ -26,7 +26,11 @@ export const REVIEW_COMPACT_WIDTH = 6;
26
26
  export const REVIEW_RESIZE_STEP = 2;
27
27
 
28
28
  /** Round a desired width to a whole column and clamp it to the bounds. */
29
- export function clampWidth(desiredWidth: number, min = REVIEW_MIN_WIDTH, max = REVIEW_MAX_WIDTH): number {
29
+ export function clampWidth(
30
+ desiredWidth: number,
31
+ min = REVIEW_MIN_WIDTH,
32
+ max = REVIEW_MAX_WIDTH,
33
+ ): number {
30
34
  return Math.max(min, Math.min(max, Math.round(desiredWidth)));
31
35
  }
32
36
 
@@ -36,7 +40,10 @@ export function clampWidth(desiredWidth: number, min = REVIEW_MIN_WIDTH, max = R
36
40
  * (an extremely narrow terminal is the auto-collapse case, handled elsewhere).
37
41
  */
38
42
  export function maxWidthForTerminal(terminalWidth: number): number {
39
- return Math.max(REVIEW_MIN_WIDTH, Math.min(REVIEW_MAX_WIDTH, terminalWidth - REVIEW_PLAN_MIN_WIDTH - 1));
43
+ return Math.max(
44
+ REVIEW_MIN_WIDTH,
45
+ Math.min(REVIEW_MAX_WIDTH, terminalWidth - REVIEW_PLAN_MIN_WIDTH - 1),
46
+ );
40
47
  }
41
48
 
42
49
  /** Clamp a desired rail width to the bounds AND the terminal, so the plan keeps its room. */
package/src/serve.test.ts CHANGED
@@ -38,7 +38,11 @@ afterEach(async () => {
38
38
  });
39
39
 
40
40
  /** Connect, open a PTY shell, resolve with the bytes seen until `until` matches. */
41
- function sshCapture(port: number, until: (frame: string) => boolean, timeoutMs = 8000): Promise<string> {
41
+ function sshCapture(
42
+ port: number,
43
+ until: (frame: string) => boolean,
44
+ timeoutMs = 8000,
45
+ ): Promise<string> {
42
46
  return new Promise((resolve, reject) => {
43
47
  const conn = new Client();
44
48
  let out = "";
@@ -48,7 +52,8 @@ function sshCapture(port: number, until: (frame: string) => boolean, timeoutMs =
48
52
  done = true;
49
53
  clearTimeout(timer);
50
54
  conn.end();
51
- err ? reject(err) : resolve(out);
55
+ if (err) reject(err);
56
+ else resolve(out);
52
57
  };
53
58
  const timer = setTimeout(() => finish(new Error(`timed out; captured:\n${out}`)), timeoutMs);
54
59
  conn
@@ -80,7 +85,10 @@ describe("cueloop serve", () => {
80
85
 
81
86
  test("an anonymous ssh client gets the observer TUI for the session", async () => {
82
87
  // Act
83
- const bytes = await sshCapture(handle.port, (frame) => frame.includes("Rollout Plan") && frame.includes("observer"));
88
+ const bytes = await sshCapture(
89
+ handle.port,
90
+ (frame) => frame.includes("Rollout Plan") && frame.includes("observer"),
91
+ );
84
92
 
85
93
  // Assert
86
94
  expect(bytes).toContain("cueloop");
@@ -0,0 +1,253 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import { SCHEMA_VERSION, type DiffFileContents, type ReviewSession } from "@cueloop/schema";
3
+ import type { SessionClient } from "@cueloop/daemon/client";
4
+ import { createReviewController } from "./session-controller";
5
+
6
+ const PATCH = `diff --git a/src/store.ts b/src/store.ts
7
+ index 111..222 100644
8
+ --- a/src/store.ts
9
+ +++ b/src/store.ts
10
+ @@ -1,4 +1,4 @@
11
+ export class Store {
12
+ - private items = [];
13
+ + private items = new Map();
14
+ }
15
+ `;
16
+
17
+ const FILES: DiffFileContents[] = [
18
+ {
19
+ path: "src/store.ts",
20
+ oldContents: "export class Store {\n private items = [];\n}\n",
21
+ newContents: "export class Store {\n private items = new Map();\n}\n",
22
+ status: "modified",
23
+ },
24
+ ];
25
+
26
+ function diffSession(files?: DiffFileContents[]): ReviewSession {
27
+ return {
28
+ schemaVersion: SCHEMA_VERSION,
29
+ id: "ses_diff",
30
+ workspace: { repoRoot: "/repo", branch: "main" },
31
+ artifact: { type: "diff", content: PATCH, meta: {}, files },
32
+ revisions: [{ revision: 1, content: PATCH, submittedAt: "2026-01-01T00:00:00.000Z" }],
33
+ annotations: [],
34
+ verdict: null,
35
+ status: "pending",
36
+ createdAt: "2026-01-01T00:00:00.000Z",
37
+ };
38
+ }
39
+
40
+ /** A fake client that records the working copy the controller writes. */
41
+ function fakeClient(session: ReviewSession, sink: { workingCopy?: string }): SessionClient {
42
+ return {
43
+ onEvent: () => () => {},
44
+ subscribe: async () => {},
45
+ sessionGet: async () => session,
46
+ sessionList: async () => [session],
47
+ sessionSetWorkingCopy: mock(async (_id: string, content: string | undefined) => {
48
+ sink.workingCopy = content;
49
+ return { ...session, workingCopy: content };
50
+ }),
51
+ close: () => {},
52
+ } as unknown as SessionClient;
53
+ }
54
+
55
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
56
+
57
+ async function connected(session: ReviewSession) {
58
+ const sink: { workingCopy?: string } = {};
59
+ const client = fakeClient(session, sink);
60
+ const controller = createReviewController({
61
+ sessionId: session.id,
62
+ openClient: async () => client,
63
+ });
64
+ controller.connect();
65
+ await tick();
66
+ return { controller, client, sink };
67
+ }
68
+
69
+ // rows for PATCH: file(0), hunk(1), ctx(2), del(3), add(4), ctx(5)
70
+ describe("diff hunk curation", () => {
71
+ test("rejecting the only change writes an empty curated working copy", async () => {
72
+ // Arrange
73
+ const { controller, sink } = await connected(diffSession(FILES));
74
+
75
+ // Act - reject the change on the added line
76
+ controller.toggleRejectChange(4);
77
+ await tick();
78
+
79
+ // Assert
80
+ expect(controller.getSnapshot().status).toContain("change rejected");
81
+ expect(sink.workingCopy).toBe("");
82
+ });
83
+
84
+ test("both the deletion and addition rows of the change dim", async () => {
85
+ // Arrange
86
+ const { controller } = await connected(diffSession(FILES));
87
+
88
+ // Act
89
+ controller.toggleRejectChange(4);
90
+ await tick();
91
+
92
+ // Assert - the change spans the del row and the add row
93
+ expect([...controller.rejectedRows()].sort((a, b) => a - b)).toEqual([3, 4]);
94
+ });
95
+
96
+ test("toggling the same change twice restores the full diff", async () => {
97
+ // Arrange
98
+ const { controller, sink } = await connected(diffSession(FILES));
99
+
100
+ // Act - reject then restore
101
+ controller.toggleRejectChange(4);
102
+ await tick();
103
+ controller.toggleRejectChange(4);
104
+ await tick();
105
+
106
+ // Assert - no decisions left, so the working copy reverts to undefined
107
+ expect(controller.getSnapshot().status).toContain("change restored");
108
+ expect(sink.workingCopy).toBeUndefined();
109
+ expect(controller.rejectedRows().size).toBe(0);
110
+ });
111
+
112
+ test("whole-hunk reject drops the hunk from the working copy", async () => {
113
+ // Arrange
114
+ const { controller, sink } = await connected(diffSession(FILES));
115
+
116
+ // Act - reject the whole hunk from any of its rows
117
+ controller.toggleRejectHunk(3);
118
+ await tick();
119
+
120
+ // Assert - the single hunk is the whole diff, so nothing remains
121
+ expect(controller.getSnapshot().status).toContain("hunk rejected");
122
+ expect(sink.workingCopy).toBe("");
123
+ });
124
+
125
+ test("a rejected change becomes a removal card with a source, id, and preview", async () => {
126
+ // Arrange
127
+ const { controller } = await connected(diffSession(FILES));
128
+
129
+ // Act
130
+ controller.toggleRejectChange(4);
131
+ await tick();
132
+
133
+ // Assert - one item: diff source, revealing the change's row, both lines previewed
134
+ const items = controller.curationItems();
135
+ expect(items.length).toBe(1);
136
+ expect(items[0]!.source).toBe("diff");
137
+ expect(items[0]!.id).toBe("diff:src/store.ts#0#1");
138
+ expect(items[0]!.revealIndex).toBe(3);
139
+ expect(items[0]!.preview).toEqual([
140
+ "- private items = [];",
141
+ "+ private items = new Map();",
142
+ ]);
143
+ });
144
+
145
+ test("a rejected hunk becomes a removal card with the whole-hunk id", async () => {
146
+ // Arrange
147
+ const { controller } = await connected(diffSession(FILES));
148
+
149
+ // Act
150
+ controller.toggleRejectHunk(3);
151
+ await tick();
152
+
153
+ // Assert - the whole hunk, id diff:path#hunk#hunk
154
+ const items = controller.curationItems();
155
+ expect(items.length).toBe(1);
156
+ expect(items[0]!.source).toBe("diff");
157
+ expect(items[0]!.id).toBe("diff:src/store.ts#0#hunk");
158
+ });
159
+
160
+ test("restoreCuration drops the diff removal and reverts the working copy", async () => {
161
+ // Arrange - one change rejected
162
+ const { controller, sink } = await connected(diffSession(FILES));
163
+ controller.toggleRejectChange(4);
164
+ await tick();
165
+ const [item] = controller.curationItems();
166
+
167
+ // Act
168
+ controller.restoreCuration(item!.id);
169
+ await tick();
170
+
171
+ // Assert - the list empties and the working copy reverts to the full diff
172
+ expect(controller.getSnapshot().status).toContain("removal restored");
173
+ expect(controller.curationItems().length).toBe(0);
174
+ expect(sink.workingCopy).toBeUndefined();
175
+ expect(controller.rejectedRows().size).toBe(0);
176
+ });
177
+
178
+ test("restoreCuration ignores an unknown id", async () => {
179
+ // Arrange - one change rejected
180
+ const { controller } = await connected(diffSession(FILES));
181
+ controller.toggleRejectChange(4);
182
+ await tick();
183
+
184
+ // Act
185
+ controller.restoreCuration("diff:nope#0#0");
186
+ await tick();
187
+
188
+ // Assert - the item stays
189
+ expect(controller.curationItems().length).toBe(1);
190
+ });
191
+
192
+ test("curation is disabled without full file contents", async () => {
193
+ // Arrange - a legacy diff with no artifact.files
194
+ const { controller, sink } = await connected(diffSession(undefined));
195
+
196
+ // Act
197
+ controller.toggleRejectChange(4);
198
+ await tick();
199
+
200
+ // Assert
201
+ expect(controller.getSnapshot().status).toContain("hunk curation needs full file contents");
202
+ expect(sink.workingCopy).toBeUndefined();
203
+ });
204
+ });
205
+
206
+ const PLAN_CONTENT = "# Title\n\nFirst paragraph.\n\nSecond paragraph.\n";
207
+ // the working copy with the "Second paragraph." block cut out
208
+ const PLAN_CUT = "# Title\n\nFirst paragraph.\n";
209
+
210
+ function planSession(workingCopy?: string): ReviewSession {
211
+ return {
212
+ schemaVersion: SCHEMA_VERSION,
213
+ id: "ses_plan",
214
+ workspace: { repoRoot: "/repo", branch: "main" },
215
+ artifact: { type: "plan", content: PLAN_CONTENT, meta: {} },
216
+ revisions: [{ revision: 1, content: PLAN_CONTENT, submittedAt: "2026-01-01T00:00:00.000Z" }],
217
+ annotations: [],
218
+ verdict: null,
219
+ status: "pending",
220
+ createdAt: "2026-01-01T00:00:00.000Z",
221
+ workingCopy,
222
+ };
223
+ }
224
+
225
+ describe("plan cut removals", () => {
226
+ test("a cut block becomes a plan removal card previewing its content", async () => {
227
+ // Arrange - a plan whose working copy dropped the second paragraph
228
+ const { controller } = await connected(planSession(PLAN_CUT));
229
+
230
+ // Act
231
+ const items = controller.curationItems();
232
+
233
+ // Assert - one plan-source removal, keyed on the base line range, content previewed
234
+ expect(items.length).toBe(1);
235
+ expect(items[0]!.source).toBe("plan");
236
+ expect(items[0]!.id).toBe("plan:4-4");
237
+ expect(items[0]!.preview).toEqual(["Second paragraph."]);
238
+ });
239
+
240
+ test("restoreCuration re-inserts the cut block and returns to pristine", async () => {
241
+ // Arrange
242
+ const { controller, sink } = await connected(planSession(PLAN_CUT));
243
+ const [item] = controller.curationItems();
244
+
245
+ // Act
246
+ controller.restoreCuration(item!.id);
247
+ await tick();
248
+
249
+ // Assert - restoring the only cut round-trips to the submitted revision
250
+ expect(controller.getSnapshot().status).toContain("removal restored");
251
+ expect(sink.workingCopy).toBeUndefined();
252
+ });
253
+ });