@cueloop/client 0.1.0-alpha.32 → 0.1.0-alpha.34

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 (101) hide show
  1. package/package.json +27 -26
  2. package/src/App.autoclose.test.tsx +7 -4
  3. package/src/App.diff.test.tsx +131 -5
  4. package/src/App.export.test.tsx +23 -5
  5. package/src/App.plan-review.test.tsx +133 -32
  6. package/src/App.readonly.test.tsx +35 -13
  7. package/src/App.rendering.test.tsx +14 -4
  8. package/src/App.review-panel.test.tsx +27 -15
  9. package/src/App.submit-confirm.test.tsx +14 -13
  10. package/src/App.test.tsx +50 -13
  11. package/src/App.toast.test.tsx +75 -0
  12. package/src/App.tsx +505 -101
  13. package/src/App.walk.test.tsx +31 -11
  14. package/src/attribution.ts +8 -2
  15. package/src/clipboard.ts +5 -1
  16. package/src/components/AnnotationCard.stories.tsx +29 -10
  17. package/src/components/AnnotationCard.tsx +57 -37
  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 +23 -5
  24. package/src/components/ConfirmDialog.tsx +10 -2
  25. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  26. package/src/components/DiffSheet.stories.tsx +35 -1
  27. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  28. package/src/components/DiffSheet.tsx +213 -101
  29. package/src/components/InboxList.stories.tsx +16 -3
  30. package/src/components/InboxList.tsx +17 -10
  31. package/src/components/KeybindsDialog.stories.tsx +31 -0
  32. package/src/components/KeybindsDialog.tsx +58 -0
  33. package/src/components/MarkerPopover.stories.tsx +58 -0
  34. package/src/components/MarkerPopover.tsx +102 -0
  35. package/src/components/MenuBar.stories.tsx +37 -0
  36. package/src/components/MenuBar.tsx +74 -0
  37. package/src/components/PlanSheet.stories.tsx +35 -3
  38. package/src/components/PlanSheet.tsx +160 -100
  39. package/src/components/PromptDialog.tsx +19 -3
  40. package/src/components/ReviewPanel.stories.tsx +17 -19
  41. package/src/components/ReviewPanel.tsx +17 -32
  42. package/src/components/ReviewRail.stories.tsx +159 -47
  43. package/src/components/ReviewRail.tsx +206 -62
  44. package/src/components/SettingsDialog.stories.tsx +51 -3
  45. package/src/components/SettingsDialog.tsx +11 -5
  46. package/src/components/SettingsRows.stories.tsx +1 -1
  47. package/src/components/SettingsRows.tsx +19 -3
  48. package/src/components/Toast.stories.tsx +12 -0
  49. package/src/components/Toast.tsx +44 -0
  50. package/src/components/WalkWizard.stories.tsx +13 -2
  51. package/src/components/WalkWizard.tsx +9 -3
  52. package/src/components/__snapshots__/stories.test.tsx.snap +853 -623
  53. package/src/components/diff-sheet-layout.test.ts +127 -0
  54. package/src/components/diff-sheet-layout.ts +167 -0
  55. package/src/components/primitives/Button.stories.tsx +2 -2
  56. package/src/components/primitives/Button.tsx +5 -1
  57. package/src/components/primitives/Dialog.tsx +12 -1
  58. package/src/components/primitives/Tabs.tsx +40 -39
  59. package/src/components/stories-app.tsx +34 -6
  60. package/src/components/story-fixtures.ts +11 -3
  61. package/src/components/story.ts +3 -1
  62. package/src/components/syntax-highlight.ts +47 -12
  63. package/src/components/theme-context.stories.tsx +6 -1
  64. package/src/components/theme-context.tsx +7 -1
  65. package/src/config.test.ts +178 -2
  66. package/src/config.ts +121 -23
  67. package/src/diff-hunk-curate.test.ts +315 -0
  68. package/src/diff-hunk-curate.ts +233 -0
  69. package/src/diff-intraline.test.ts +164 -0
  70. package/src/diff-intraline.ts +132 -0
  71. package/src/diff-syntax.test.ts +96 -0
  72. package/src/diff-syntax.ts +154 -0
  73. package/src/editor.test.ts +13 -4
  74. package/src/editor.ts +31 -5
  75. package/src/herdr.ts +8 -2
  76. package/src/index.ts +9 -1
  77. package/src/integrations.ts +3 -1
  78. package/src/intent-dispatch.test.ts +193 -9
  79. package/src/intent-dispatch.ts +151 -18
  80. package/src/key-bindings.test.ts +19 -9
  81. package/src/key-bindings.ts +77 -16
  82. package/src/keymap.test.ts +171 -26
  83. package/src/keymap.ts +83 -16
  84. package/src/review-panel.test.ts +3 -1
  85. package/src/review-panel.ts +9 -2
  86. package/src/serve.test.ts +13 -5
  87. package/src/session-controller.curate.test.ts +253 -0
  88. package/src/session-controller.share.test.ts +77 -16
  89. package/src/session-controller.ts +319 -26
  90. package/src/share.ts +41 -8
  91. package/src/test-support.ts +97 -23
  92. package/src/theme-presets.ts +168 -0
  93. package/src/theme.ts +1 -3
  94. package/src/version.ts +5 -0
  95. package/src/view-diff.ts +15 -4
  96. package/src/view-plan.test.ts +40 -6
  97. package/src/view-plan.ts +71 -28
  98. package/src/walk.test.ts +7 -2
  99. package/src/walk.ts +7 -2
  100. package/src/components/ComposeBar.stories.tsx +0 -18
  101. package/src/components/ComposeBar.tsx +0 -54
@@ -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,10 +73,49 @@ 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
+
85
+ export interface ToastState {
86
+ title?: string;
87
+ body: string;
88
+ }
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
+
53
114
  export interface ControllerSnapshot {
54
115
  session: ReviewSession | null;
55
116
  inbox: ReviewSession[] | null;
56
117
  status: string;
118
+ toast: ToastState | null;
57
119
  error: string | null;
58
120
  completion: Completion;
59
121
  /**
@@ -96,6 +158,8 @@ export interface ReviewController {
96
158
  /** Loaded config parts the controller acts on: auto-close and exporters. */
97
159
  applyConfig(config: CueloopConfig): void;
98
160
  setStatus(message: string): void;
161
+ showToast(body: string, title?: string): void;
162
+ dismissToast(): void;
99
163
  /** Derived projections, cached per session identity. */
100
164
  display(): DisplayBlock[];
101
165
  rows(): DiffRow[];
@@ -110,13 +174,29 @@ export interface ReviewController {
110
174
  setSelfName(name: string): void;
111
175
  /** Cut the block under the cursor, or restore a cut one. */
112
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;
113
187
  /** The $EDITOR hand-off on the working copy. */
114
188
  edit(): void;
115
189
  /**
116
190
  * Anchor and store an annotation; both plan and diff anchor constructions.
117
191
  * Returns the minted annotation id so the view can select the new card.
118
192
  */
119
- 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;
120
200
  /** Rewrite a stored annotation's body in place (the rail-card edit). */
121
201
  updateAnnotation(id: string, body: string): void;
122
202
  removeAnnotation(id: string): void;
@@ -161,6 +241,7 @@ class Controller implements ReviewController {
161
241
  session: null,
162
242
  inbox: null,
163
243
  status: "",
244
+ toast: null,
164
245
  error: null,
165
246
  completion: { phase: "idle" },
166
247
  editOrphanCount: 0,
@@ -176,11 +257,20 @@ class Controller implements ReviewController {
176
257
  private shareRun: object | null = null;
177
258
  /** Projections keyed by session identity so renders reuse one computation. */
178
259
  private derivedFor: ReviewSession | null = null;
179
- 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
+ } = {
180
267
  display: [],
181
268
  rows: [],
182
269
  files: [],
270
+ models: new Map(),
183
271
  };
272
+ /** The owner's per-hunk/change reject decisions for the open diff session. */
273
+ private rejections: HunkRejection[] = [];
184
274
 
185
275
  constructor(private readonly options: ReviewControllerOptions) {
186
276
  this.readOnly = options.readOnly ?? false;
@@ -202,7 +292,9 @@ class Controller implements ReviewController {
202
292
  connect(): void {
203
293
  void (async () => {
204
294
  try {
205
- 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 }));
206
298
  const client = await openClient();
207
299
  if (this.closed) return void client.close();
208
300
  this.client = client;
@@ -248,16 +340,28 @@ class Controller implements ReviewController {
248
340
  this.update({ status: message });
249
341
  }
250
342
 
343
+ showToast(body: string, title?: string): void {
344
+ this.update({ toast: { body, title } });
345
+ }
346
+
347
+ dismissToast(): void {
348
+ if (this.snapshot.toast) this.update({ toast: null });
349
+ }
350
+
251
351
  // ── derived projections ─────────────────────
252
352
  private ensureDerived(): void {
253
353
  const session = this.snapshot.session;
254
354
  if (this.derivedFor === session) return;
255
355
  this.derivedFor = session;
256
- 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));
257
360
  this.derived = {
258
361
  display: session ? buildDisplay(session.artifact.content, session.workingCopy) : [],
259
362
  rows,
260
363
  files: walkFiles(rows),
364
+ models,
261
365
  };
262
366
  }
263
367
 
@@ -302,14 +406,17 @@ class Controller implements ReviewController {
302
406
 
303
407
  /** Optimistic apply: the daemon response is the next session snapshot. */
304
408
  private apply(mutation: Promise<ReviewSession>): void {
305
- mutation.then((session) => this.update({ session })).catch((error: unknown) =>
306
- this.setStatus(String(error instanceof Error ? error.message : error)),
307
- );
409
+ mutation
410
+ .then((session) => this.update({ session }))
411
+ .catch((error: unknown) =>
412
+ this.setStatus(String(error instanceof Error ? error.message : error)),
413
+ );
308
414
  }
309
415
 
310
416
  // ── verbs ───────────────────────────────────
311
417
  open(id: string): void {
312
418
  this.locallyViewed.clear();
419
+ this.rejections = [];
313
420
  const cached = this.snapshot.inbox?.find((candidate) => candidate.id === id);
314
421
  if (cached) this.update({ session: cached });
315
422
  else void this.refreshSession(id);
@@ -319,7 +426,9 @@ class Controller implements ReviewController {
319
426
  this.client
320
427
  ?.sessionDelete(id)
321
428
  .then(() => this.setStatus("plan deleted"))
322
- .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
+ );
323
432
  }
324
433
 
325
434
  setSelfName(name: string): void {
@@ -335,18 +444,185 @@ class Controller implements ReviewController {
335
444
  if (!block) return;
336
445
  const working = this.working();
337
446
  if (block.type === "del") {
338
- const line = restoreLine(nextWorkBlock(this.display(), displayIndex), working.split("\n").length);
339
- // restoreBlock returns undefined when the block structure round-trips
340
- // to the submitted revision - the working copy is gone
341
- const restored = restoreBlock(session.artifact.content, working, block.base!, line);
342
- this.setWorkingCopy(restored);
343
- this.setStatus("cut restored");
447
+ this.restoreDelBlock(block, displayIndex);
344
448
  } else if (block.work) {
345
449
  this.setWorkingCopy(cutBlock(working, block.work));
346
450
  this.setStatus("block cut - it serializes into the diff");
347
451
  }
348
452
  }
349
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
+
350
626
  edit(): void {
351
627
  const session = this.snapshot.session;
352
628
  if (!session || session.status === "resolved") return;
@@ -377,7 +653,7 @@ class Controller implements ReviewController {
377
653
  }
378
654
 
379
655
  annotate(
380
- kind: "comment" | "suggestion",
656
+ kind: "comment",
381
657
  displayIndex: number,
382
658
  start: number,
383
659
  end: number,
@@ -391,14 +667,15 @@ class Controller implements ReviewController {
391
667
  } else {
392
668
  const display = this.display();
393
669
  const workBlocks = display.filter((entry) => entry.work).map((entry) => entry.work!);
394
- 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;
395
672
  anchor = makeAnchor(workBlocks, workBlockIndex, start, end);
396
673
  }
397
674
  const wire = { id: newAnnotationId(), kind, anchor, body };
398
675
  const persisted = this.client!.sessionAnnotate(session.id, wire);
399
676
  this.apply(persisted);
400
677
  this.mirrorAnnotation(persisted, wire);
401
- this.setStatus(kind === "suggestion" ? "suggestion added - the agent applies it" : "comment added");
678
+ this.setStatus("comment added");
402
679
  return wire.id;
403
680
  }
404
681
 
@@ -416,7 +693,10 @@ class Controller implements ReviewController {
416
693
  }
417
694
 
418
695
  // push only after the local write lands, so a rejected write never leaks to the share
419
- private mirrorAnnotation(persisted: Promise<ReviewSession>, annotation: Omit<Annotation, "createdAt">): void {
696
+ private mirrorAnnotation(
697
+ persisted: Promise<ReviewSession>,
698
+ annotation: Omit<Annotation, "createdAt">,
699
+ ): void {
420
700
  const shareId = this.snapshot.session?.shareId;
421
701
  if (!shareId) return;
422
702
  void persisted.then(() => pushShare(shareId, [annotation])).catch(() => {});
@@ -492,7 +772,11 @@ class Controller implements ReviewController {
492
772
  for (const exporter of this.exporters) {
493
773
  if (!exporter.runsOn(verdict)) continue;
494
774
  void exporter.run(resolved).then((exportResult) => {
495
- 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
+ );
496
780
  });
497
781
  }
498
782
  // Hand the reviewer back to the agent. The default is a visible
@@ -503,7 +787,9 @@ class Controller implements ReviewController {
503
787
  else if (typeof delay === "number") this.startCounting(delay);
504
788
  else this.startCounting(DEFAULT_AUTO_CLOSE);
505
789
  })
506
- .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
+ );
507
793
  }
508
794
 
509
795
  share(): void {
@@ -515,9 +801,12 @@ class Controller implements ReviewController {
515
801
  // Stamp the id back so a later pull knows which share to collect from.
516
802
  const shareId = shareIdFromLine(line);
517
803
  if (shareId && this.client) await this.client.sessionSetShareId(session.id, shareId);
518
- this.setStatus(copied ? `✓ share link copied - ${line}` : line);
804
+ this.setStatus("");
805
+ this.showToast(line, copied ? "share link copied" : "share link");
519
806
  })
520
- .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
+ );
521
810
  }
522
811
 
523
812
  /**
@@ -532,7 +821,10 @@ class Controller implements ReviewController {
532
821
  const client = this.client;
533
822
  return pullShare(session.shareId)
534
823
  .then((remote) =>
535
- client.sessionMergeShared(session.id, { annotations: collaboratorAnnotations(remote), participants: remote.participants }),
824
+ client.sessionMergeShared(session.id, {
825
+ annotations: collaboratorAnnotations(remote),
826
+ participants: remote.participants,
827
+ }),
536
828
  )
537
829
  .then(() => {})
538
830
  .catch(() => {});
@@ -550,7 +842,8 @@ class Controller implements ReviewController {
550
842
  this.shareRun = run;
551
843
  const tick = (): void => {
552
844
  void this.pullShared().finally(() => {
553
- 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);
554
847
  });
555
848
  };
556
849
  tick();
package/src/share.ts CHANGED
@@ -6,7 +6,12 @@
6
6
  * Share button, so the two share exactly the same path.
7
7
  */
8
8
 
9
- import { DEFAULT_SHARE_HOST, DEFAULT_SHARE_PORT, SHARE_UPLOAD_USER, packSessionBlob } from "@cueloop/daemon/share-blob";
9
+ import {
10
+ DEFAULT_SHARE_HOST,
11
+ DEFAULT_SHARE_PORT,
12
+ SHARE_UPLOAD_USER,
13
+ packSessionBlob,
14
+ } from "@cueloop/daemon/share-blob";
10
15
  import type { Annotation, ReviewSession } from "@cueloop/schema";
11
16
  import { copyToClipboard } from "./clipboard";
12
17
 
@@ -28,9 +33,17 @@ export function shareIdFromLine(line: string): string | undefined {
28
33
  }
29
34
 
30
35
  /** Upload the session to the gateway and copy the resulting ssh line. */
31
- export async function publishShare(session: ReviewSession, target: ShareTarget = {}): Promise<ShareResult> {
32
- const { stdout, stderr, code } = await runShareSsh("cueloop-share", packSessionBlob(session), target);
33
- if (code !== 0) throw new Error(`gateway upload failed: ${stderr.trim() || `ssh exited ${code}`}`);
36
+ export async function publishShare(
37
+ session: ReviewSession,
38
+ target: ShareTarget = {},
39
+ ): Promise<ShareResult> {
40
+ const { stdout, stderr, code } = await runShareSsh(
41
+ "cueloop-share",
42
+ packSessionBlob(session),
43
+ target,
44
+ );
45
+ if (code !== 0)
46
+ throw new Error(`gateway upload failed: ${stderr.trim() || `ssh exited ${code}`}`);
34
47
  const line = stdout.trim();
35
48
  if (!line.startsWith("ssh ")) throw new Error(`unexpected gateway reply: ${line || "(empty)"}`);
36
49
  const copied = await copyToClipboard(line);
@@ -58,17 +71,37 @@ export function collaboratorAnnotations(session: ReviewSession): Annotation[] {
58
71
  * them on their next refresh. Owner-gated at the gateway; the notes stay
59
72
  * unauthored (the planner's), unioned by id.
60
73
  */
61
- export async function pushShare(shareId: string, annotations: Array<Omit<Annotation, "createdAt">>, target: ShareTarget = {}): Promise<void> {
62
- const { stderr, code } = await runShareSsh("cueloop-push", Buffer.from(JSON.stringify({ shareId, annotations })), target);
74
+ export async function pushShare(
75
+ shareId: string,
76
+ annotations: Array<Omit<Annotation, "createdAt">>,
77
+ target: ShareTarget = {},
78
+ ): Promise<void> {
79
+ const { stderr, code } = await runShareSsh(
80
+ "cueloop-push",
81
+ Buffer.from(JSON.stringify({ shareId, annotations })),
82
+ target,
83
+ );
63
84
  if (code !== 0) throw new Error(`gateway push failed: ${stderr.trim() || `ssh exited ${code}`}`);
64
85
  }
65
86
 
66
87
  /** Run one `share@host:port` ssh command with `stdin`; hand back its streams and exit code. */
67
- async function runShareSsh(command: string, stdin: Buffer, target: ShareTarget): Promise<{ stdout: string; stderr: string; code: number }> {
88
+ async function runShareSsh(
89
+ command: string,
90
+ stdin: Buffer,
91
+ target: ShareTarget,
92
+ ): Promise<{ stdout: string; stderr: string; code: number }> {
68
93
  const host = target.host ?? DEFAULT_SHARE_HOST;
69
94
  const port = target.port ?? DEFAULT_SHARE_PORT;
70
95
  const proc = Bun.spawn(
71
- ["ssh", "-p", String(port), "-o", "StrictHostKeyChecking=accept-new", `${SHARE_UPLOAD_USER}@${host}`, command],
96
+ [
97
+ "ssh",
98
+ "-p",
99
+ String(port),
100
+ "-o",
101
+ "StrictHostKeyChecking=accept-new",
102
+ `${SHARE_UPLOAD_USER}@${host}`,
103
+ command,
104
+ ],
72
105
  { stdin, stdout: "pipe", stderr: "pipe" },
73
106
  );
74
107
  const [stdout, stderr, code] = await Promise.all([