@cueloop/client 0.1.0-alpha.8 → 0.1.0-alpha.9

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.
package/src/App.tsx CHANGED
@@ -1,23 +1,33 @@
1
1
  /**
2
- * The cueloop TUI (#22): Ledger IA reduced to its terminal shape - header,
3
- * center artifact pane (projection renderer), right review rail, footer hint
2
+ * The cueloop TUI (#22, #71): header, plan sheet (sheet header + inline
3
+ * compose + selectable lines) or diff pane, right review rail, footer hint
4
4
  * bar. Thin renderer over the review-session controller: daemon IO and the
5
5
  * mutation verbs live in session-controller.ts, the keyboard grammar in
6
- * keymap.ts; local state is view state only (cursor, span, overlays).
6
+ * keymap.ts; local state is view state only (cursor, selection, overlays).
7
+ *
8
+ * The plan review grammar: selection is the entry primitive (mouse drag or
9
+ * keyboard span on one native renderer selection), compose happens inline
10
+ * under the anchor, annotation text lives in the rail while the document
11
+ * keeps only the kind-colored highlight, and the rail edits what the
12
+ * document selects - one selected id drives both sides.
7
13
  */
8
14
 
9
15
  import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
10
- import { useKeyboard } from "@opentui/react";
16
+ import { useKeyboard, useRenderer } from "@opentui/react";
17
+ import type { ScrollBoxRenderable, TextRenderable } from "@opentui/core";
11
18
  import { type Annotation, type ReviewSession, type VerdictKind } from "@cueloop/schema";
12
19
  import {
13
20
  blockRuns,
21
+ composeBoxHeight,
14
22
  displayText,
15
23
  marksByDisplay,
16
24
  overlayMarks,
25
+ revisionDelta,
17
26
  spanKey,
18
27
  startSpan,
19
28
  wrapRuns,
20
29
  type DisplayBlock,
30
+ type Mark,
21
31
  type SpanState,
22
32
  type StyleRun,
23
33
  } from "./view";
@@ -44,8 +54,11 @@ type Mode =
44
54
  | { m: "normal" }
45
55
  | { m: "span"; span: SpanState }
46
56
  | { m: "compose"; kind: "comment" | "suggestion"; dispIdx: number; start: number; end: number; text: string }
57
+ | { m: "railEdit"; id: string; text: string }
47
58
  | { m: "submit"; verdict: VerdictKind; summary: string };
48
59
 
60
+ type RailTab = "review" | "agent";
61
+
49
62
  const VERDICTS: VerdictKind[] = ["comment", "approve", "request_changes"];
50
63
  const VERDICT_LABEL: Record<VerdictKind, string> = {
51
64
  comment: "Comment",
@@ -53,6 +66,22 @@ const VERDICT_LABEL: Record<VerdictKind, string> = {
53
66
  request_changes: "Request changes",
54
67
  };
55
68
 
69
+ /** One rendered plan line, registered for the native selection primitive. */
70
+ interface PlanLineRef {
71
+ renderable: TextRenderable;
72
+ dispIdx: number;
73
+ lineIndex: number;
74
+ /** Block-text offset of the line's first positioned run; null for pure-del lines. */
75
+ lineStart: number | null;
76
+ /** Screen columns before the run text (cursor glyph + list marker). */
77
+ prefixColumns: number;
78
+ /** Length of the positioned run text in this line. */
79
+ textLength: number;
80
+ }
81
+
82
+ /** Rail card height (3 text rows + 1 margin), for reveal-scroll math. */
83
+ const RAIL_CARD_HEIGHT = 4;
84
+
56
85
  export function App({ home, sessionId, readOnly = false, onExit }: AppProps): React.ReactNode {
57
86
  const controller = useMemo(
58
87
  () => createReviewController({ home, sessionId, readOnly, onExit }),
@@ -63,20 +92,29 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
63
92
  controller.connect();
64
93
  return () => controller.close();
65
94
  }, [controller]);
66
- const { session, inbox, status, error, completion } = useSyncExternalStore(
95
+ const { session, inbox, status, error, completion, editOrphanCount } = useSyncExternalStore(
67
96
  controller.subscribe,
68
97
  controller.getSnapshot,
69
98
  controller.getSnapshot,
70
99
  );
100
+ const renderer = useRenderer();
71
101
 
72
102
  // ── view state ──────────────────────────────
73
103
  const [cursor, setCursor] = useState(0);
74
104
  const [inboxCursor, setInboxCursor] = useState(0);
75
105
  const [mode, setMode] = useState<Mode>({ m: "normal" });
76
106
  const [focusedAnn, setFocusedAnn] = useState<string | undefined>(undefined);
107
+ const [railTab, setRailTab] = useState<RailTab>("review");
108
+ // ~2s focus pulse on the document highlight when a rail card is activated
109
+ const [pulseAnn, setPulseAnn] = useState<string | null>(null);
110
+ const pulseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
77
111
  // live mirror of overlay input text: refs commit synchronously, so the
78
112
  // RETURN handler never reads a stale value mid-typing
79
113
  const liveInput = useRef("");
114
+ // rendered plan lines, for driving and reading the native selection
115
+ const lineRefs = useRef(new Map<string, PlanLineRef>());
116
+ const docScrollRef = useRef<ScrollBoxRenderable | null>(null);
117
+ const railScrollRef = useRef<ScrollBoxRenderable | null>(null);
80
118
  // keymap from layered config; theme overrides land on the shared tokens
81
119
  const keysRef = useRef(DEFAULT_KEYS);
82
120
  useEffect(() => {
@@ -85,17 +123,133 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
85
123
  Object.assign(T, cfg.theme);
86
124
  controller.applyConfig(cfg);
87
125
  }, [session?.workspace.repoRoot, controller]);
126
+ useEffect(
127
+ () => () => {
128
+ if (pulseTimer.current) clearTimeout(pulseTimer.current);
129
+ },
130
+ [],
131
+ );
88
132
 
89
133
  // ── derived view model ──────────────────────
90
134
  const display = controller.display();
91
135
  const rows = controller.rows();
92
136
  const marks = useMemo(
93
- () => (session ? marksByDisplay(session.annotations, display, focusedAnn) : new Map()),
94
- [session, display, focusedAnn],
137
+ () => (session ? marksByDisplay(session.annotations, display, pulseAnn ?? undefined) : new Map<number, Mark[]>()),
138
+ [session, display, pulseAnn],
95
139
  );
140
+ /** Annotation ids whose anchor resolved against the working copy. */
141
+ const resolvedIds = useMemo(() => {
142
+ const ids = new Set<string>();
143
+ for (const blockMarks of marks.values()) {
144
+ for (const mark of blockMarks) if (mark.annotationId) ids.add(mark.annotationId);
145
+ }
146
+ return ids;
147
+ }, [marks]);
96
148
  const resolved = session?.status === "resolved";
97
149
  const isDiff = session?.artifact.type === "diff";
98
150
 
151
+ // ── the native selection: keyboard driver ───
152
+ const lineRefFor = (dispIdx: number, offset: number): PlanLineRef | null => {
153
+ for (const meta of lineRefs.current.values()) {
154
+ if (meta.dispIdx !== dispIdx || meta.lineStart === null) continue;
155
+ if (offset >= meta.lineStart && offset < meta.lineStart + meta.textLength) return meta;
156
+ }
157
+ return null;
158
+ };
159
+
160
+ /** Anchor/extend the renderer's native selection from keyboard span offsets. */
161
+ const driveNativeSelection = (span: SpanState): void => {
162
+ if (!renderer) return;
163
+ const startMeta = lineRefFor(span.dispIdx, span.start);
164
+ const endMeta = lineRefFor(span.dispIdx, span.end - 1);
165
+ if (!startMeta || !endMeta) return;
166
+ const startColumn = startMeta.prefixColumns + (span.start - startMeta.lineStart!);
167
+ const endColumn = endMeta.prefixColumns + (span.end - 1 - endMeta.lineStart!);
168
+ renderer.startSelection(startMeta.renderable, startMeta.renderable.x + startColumn, startMeta.renderable.y);
169
+ renderer.updateSelection(endMeta.renderable, endMeta.renderable.x + endColumn + 1, endMeta.renderable.y);
170
+ };
171
+ // driving needs committed layout, so it runs after render; any transition
172
+ // out of span mode clears the renderer selection (compose paints its own
173
+ // mark, and a mouse drag never changes the mode, so it survives)
174
+ useEffect(() => {
175
+ if (mode.m === "span") driveNativeSelection(mode.span);
176
+ else renderer?.clearSelection();
177
+ // eslint-disable-next-line react-hooks/exhaustive-deps
178
+ }, [mode]);
179
+
180
+ /**
181
+ * Read the native selection (mouse drag) back as a block range. The quote
182
+ * anchors in the FIRST selected block - quote anchors resolve within one
183
+ * block. Columns map 1:1 onto text offsets except inside word-diffed mod
184
+ * blocks, where inline del runs shift the screen columns.
185
+ */
186
+ const readNativeSelection = (): { dispIdx: number; start: number; end: number } | null => {
187
+ if (!renderer?.hasSelection) return null;
188
+ const ordered = [...lineRefs.current.values()].sort(
189
+ (a, b) => a.dispIdx - b.dispIdx || a.lineIndex - b.lineIndex,
190
+ );
191
+ let found: { dispIdx: number; start: number; end: number } | null = null;
192
+ for (const meta of ordered) {
193
+ if (meta.lineStart === null) continue;
194
+ const selection = meta.renderable.getSelection();
195
+ if (!selection || selection.end <= selection.start) continue;
196
+ const startInText = Math.min(meta.textLength, Math.max(0, selection.start - meta.prefixColumns));
197
+ const endInText = Math.min(meta.textLength, Math.max(0, selection.end - meta.prefixColumns));
198
+ if (endInText <= startInText) continue;
199
+ const blockStart = meta.lineStart + startInText;
200
+ const blockEnd = meta.lineStart + endInText;
201
+ if (!found) found = { dispIdx: meta.dispIdx, start: blockStart, end: blockEnd };
202
+ else if (found.dispIdx === meta.dispIdx) found.end = Math.max(found.end, blockEnd);
203
+ }
204
+ return found;
205
+ };
206
+
207
+ // ── selection symmetry: one selected id, both sides ──
208
+ const pulse = (id: string): void => {
209
+ setPulseAnn(id);
210
+ if (pulseTimer.current) clearTimeout(pulseTimer.current);
211
+ pulseTimer.current = setTimeout(() => setPulseAnn(null), 2000);
212
+ };
213
+
214
+ const revealRailCard = (annotationId: string): void => {
215
+ const index = session?.annotations.findIndex((annotation) => annotation.id === annotationId) ?? -1;
216
+ if (index >= 0) railScrollRef.current?.scrollTo(Math.max(0, index * RAIL_CARD_HEIGHT - 1));
217
+ };
218
+
219
+ /** Card activation scrolls the document to the anchor and pulses it. */
220
+ const revealAnchor = (annotationId: string): void => {
221
+ for (const [dispIdx, blockMarks] of marks) {
222
+ if (!blockMarks.some((mark) => mark.annotationId === annotationId)) continue;
223
+ setCursor(dispIdx);
224
+ try {
225
+ docScrollRef.current?.scrollChildIntoView(`plan-block-${dispIdx}`);
226
+ } catch {
227
+ // reveal is best-effort; selection state is already correct
228
+ }
229
+ return;
230
+ }
231
+ };
232
+
233
+ const selectCardFromDocument = (annotationId: string): void => {
234
+ setFocusedAnn(annotationId);
235
+ revealRailCard(annotationId);
236
+ };
237
+
238
+ const selectCardFromRail = (annotationId: string): void => {
239
+ setFocusedAnn(annotationId);
240
+ pulse(annotationId);
241
+ revealAnchor(annotationId);
242
+ };
243
+
244
+ const openCardEdit = (annotationId: string): void => {
245
+ if (readOnly) return controller.setStatus("observer - read-only");
246
+ if (resolved) return controller.setStatus("review submitted - read-only");
247
+ const annotation = session?.annotations.find((candidate) => candidate.id === annotationId);
248
+ if (!annotation) return;
249
+ liveInput.current = annotation.body;
250
+ setMode({ m: "railEdit", id: annotation.id, text: annotation.body });
251
+ };
252
+
99
253
  // ── keyboard grammar: build state, reduce, dispatch ──
100
254
  const dispatch = (intent: Intent): void => {
101
255
  switch (intent.t) {
@@ -130,7 +284,9 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
130
284
  }
131
285
  case "spanKey":
132
286
  if (mode.m === "span") {
133
- setMode({ m: "span", span: spanKey(mode.span, intent.name, displayText(display[mode.span.dispIdx]!)) });
287
+ const span = spanKey(mode.span, intent.name, displayText(display[mode.span.dispIdx]!));
288
+ setMode({ m: "span", span });
289
+ driveNativeSelection(span);
134
290
  }
135
291
  return;
136
292
  case "openCompose": {
@@ -148,8 +304,14 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
148
304
  const row = rows[cursor];
149
305
  if (row) setMode({ m: "compose", kind: intent.kind, dispIdx: cursor, start: 0, end: row.text.length, text: "" });
150
306
  } else {
151
- const d = display[cursor];
152
- if (d) setMode({ m: "compose", kind: intent.kind, dispIdx: cursor, start: 0, end: displayText(d).length, text: "" });
307
+ // a mouse drag leaves a native selection; it wins over the cursor block
308
+ const native = readNativeSelection();
309
+ if (native) {
310
+ setMode({ m: "compose", kind: intent.kind, ...native, text: "" });
311
+ } else {
312
+ const d = display[cursor];
313
+ if (d) setMode({ m: "compose", kind: intent.kind, dispIdx: cursor, start: 0, end: displayText(d).length, text: "" });
314
+ }
153
315
  }
154
316
  return;
155
317
  }
@@ -161,13 +323,16 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
161
323
  return controller.cut(cursor);
162
324
  case "edit":
163
325
  return controller.edit();
326
+ case "editCard":
327
+ if (focusedAnn) openCardEdit(focusedAnn);
328
+ return;
164
329
  case "nextAnn":
165
330
  case "prevAnn": {
166
331
  const anns = session?.annotations ?? [];
167
332
  if (!anns.length) return;
168
333
  const idx = anns.findIndex((a) => a.id === focusedAnn);
169
334
  const next = idx === -1 ? 0 : (idx + (intent.t === "nextAnn" ? 1 : -1) + anns.length) % anns.length;
170
- return void setFocusedAnn(anns[next]!.id);
335
+ return void selectCardFromDocument(anns[next]!.id);
171
336
  }
172
337
  case "removeAnnotation":
173
338
  if (focusedAnn) {
@@ -175,12 +340,24 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
175
340
  setFocusedAnn(undefined);
176
341
  }
177
342
  return;
343
+ case "deselect":
344
+ renderer?.clearSelection();
345
+ setFocusedAnn(undefined);
346
+ setPulseAnn(null);
347
+ return;
178
348
  case "closeOverlay":
179
349
  return void setMode({ m: "normal" });
180
350
  case "saveCompose": {
181
- if (mode.m !== "compose") return;
182
351
  const body = liveInput.current.trim();
183
- if (session && body) controller.annotate(mode.kind, mode.dispIdx, mode.start, mode.end, body);
352
+ if (mode.m === "railEdit") {
353
+ if (session && body) controller.updateAnnotation(mode.id, body);
354
+ return void setMode({ m: "normal" });
355
+ }
356
+ if (mode.m !== "compose") return;
357
+ if (session && body) {
358
+ const annotationId = controller.annotate(mode.kind, mode.dispIdx, mode.start, mode.end, body);
359
+ if (annotationId) setFocusedAnn(annotationId);
360
+ }
184
361
  return void setMode({ m: "normal" });
185
362
  }
186
363
  case "submitVerdict":
@@ -205,7 +382,7 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
205
382
  keys: keysRef.current,
206
383
  readOnly,
207
384
  overlay:
208
- mode.m === "compose"
385
+ mode.m === "compose" || mode.m === "railEdit"
209
386
  ? "compose"
210
387
  : mode.m === "submit"
211
388
  ? "submit"
@@ -246,6 +423,41 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
246
423
  );
247
424
  }
248
425
 
426
+ const composeState =
427
+ mode.m === "compose" && !isDiff
428
+ ? {
429
+ kind: mode.kind,
430
+ dispIdx: mode.dispIdx,
431
+ quote: displayText(display[mode.dispIdx]!).slice(mode.start, mode.end),
432
+ text: mode.text,
433
+ onInput: (text: string) => {
434
+ liveInput.current = text;
435
+ setMode({ ...mode, text });
436
+ },
437
+ onSave: () => dispatch({ t: "saveCompose" }),
438
+ onCancel: () => dispatch({ t: "closeOverlay" }),
439
+ }
440
+ : null;
441
+
442
+ const registerLine = (key: string, renderable: TextRenderable | null, meta: Omit<PlanLineRef, "renderable">): void => {
443
+ if (renderable) lineRefs.current.set(key, { renderable, ...meta });
444
+ else lineRefs.current.delete(key);
445
+ };
446
+
447
+ const onLineActivate = (dispIdx: number): void => {
448
+ // releasing a drag-selection lands here too; a live selection is not a click
449
+ if (renderer?.hasSelection) return;
450
+ setCursor(dispIdx);
451
+ const annotationId = marks.get(dispIdx)?.[0]?.annotationId;
452
+ if (annotationId) selectCardFromDocument(annotationId);
453
+ };
454
+
455
+ const onEditRequest = (): void => {
456
+ if (readOnly) return controller.setStatus("observer - read-only");
457
+ if (resolved) return controller.setStatus("review submitted - read-only");
458
+ controller.edit();
459
+ };
460
+
249
461
  return (
250
462
  <box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: T.bg }}>
251
463
  <box style={{ height: 1, backgroundColor: T.panel, paddingLeft: 1, flexDirection: "row" }}>
@@ -261,12 +473,48 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
261
473
  {isDiff ? (
262
474
  <DiffPane rows={rows} cursor={cursor} annotations={s.annotations} focusedAnn={focusedAnn} />
263
475
  ) : (
264
- <PlanPane display={display} marks={marks} cursor={cursor} mode={mode} />
476
+ <PlanPane
477
+ display={display}
478
+ marks={marks}
479
+ cursor={cursor}
480
+ mode={mode}
481
+ session={s}
482
+ editOrphanCount={editOrphanCount}
483
+ compose={composeState}
484
+ registerLine={registerLine}
485
+ onLineActivate={onLineActivate}
486
+ onEditRequest={onEditRequest}
487
+ docScrollRef={docScrollRef}
488
+ />
265
489
  )}
266
- <Rail session={s} focusedAnn={focusedAnn} pendingCount={pendingCount} />
490
+ <Rail
491
+ session={s}
492
+ selectedId={focusedAnn}
493
+ resolvedIds={isDiff ? null : resolvedIds}
494
+ railTab={railTab}
495
+ pendingCount={pendingCount}
496
+ cardEdit={
497
+ mode.m === "railEdit"
498
+ ? {
499
+ id: mode.id,
500
+ text: mode.text,
501
+ onInput: (text: string) => {
502
+ liveInput.current = text;
503
+ setMode({ m: "railEdit", id: mode.id, text });
504
+ },
505
+ onSave: () => dispatch({ t: "saveCompose" }),
506
+ onCancel: () => dispatch({ t: "closeOverlay" }),
507
+ }
508
+ : null
509
+ }
510
+ onTab={setRailTab}
511
+ onSelectCard={selectCardFromRail}
512
+ onActivateCard={openCardEdit}
513
+ railScrollRef={railScrollRef}
514
+ />
267
515
  </box>
268
- {mode.m === "compose" ? (
269
- <ComposeBar mode={mode} quote={isDiff ? (rows[mode.dispIdx]?.text ?? "") : displayText(display[mode.dispIdx]!).slice(mode.start, mode.end)} onChange={(text) => { liveInput.current = text; setMode({ ...mode, text }); }} />
516
+ {mode.m === "compose" && isDiff ? (
517
+ <ComposeBar mode={mode} quote={rows[mode.dispIdx]?.text ?? ""} onChange={(text) => { liveInput.current = text; setMode({ ...mode, text }); }} />
270
518
  ) : mode.m === "submit" ? (
271
519
  <SubmitBar mode={mode} pendingCount={pendingCount} onChange={(summary) => { liveInput.current = summary; setMode({ ...mode, summary }); }} />
272
520
  ) : (
@@ -276,7 +524,11 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
276
524
  ? "observer - read-only · j/k move · n/p annotations · q quit"
277
525
  : mode.m === "span"
278
526
  ? "span · l/h grow/shrink · w/b slide · $ end · c comment · s suggest · esc"
279
- : "j/k move · v span · c comment · s suggest · x cut · e edit · n/p annotations · ⏎ submit · q quit"}
527
+ : mode.m === "compose" || mode.m === "railEdit"
528
+ ? "typing · ⏎ save · esc cancel"
529
+ : focusedAnn !== undefined
530
+ ? "card · e edit · x Cut · n/p cards · esc deselect · ⏎ submit"
531
+ : "j/k move · v span · drag selects · c comment · s suggest · x cut · e edit · n/p annotations · ⏎ submit · q quit"}
280
532
  </text>
281
533
  </box>
282
534
  )}
@@ -286,44 +538,83 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
286
538
 
287
539
  // ── panes ─────────────────────────────────────
288
540
 
541
+ interface ComposeState {
542
+ kind: "comment" | "suggestion";
543
+ dispIdx: number;
544
+ quote: string;
545
+ text: string;
546
+ onInput: (text: string) => void;
547
+ onSave: () => void;
548
+ onCancel: () => void;
549
+ }
550
+
289
551
  function PlanPane({
290
552
  display,
291
553
  marks,
292
554
  cursor,
293
555
  mode,
556
+ session,
557
+ editOrphanCount,
558
+ compose,
559
+ registerLine,
560
+ onLineActivate,
561
+ onEditRequest,
562
+ docScrollRef,
294
563
  }: {
295
564
  display: DisplayBlock[];
296
- marks: Map<number, { start: number; end: number; role: StyleRun["role"]; annotationId?: string }[]>;
565
+ marks: Map<number, Mark[]>;
297
566
  cursor: number;
298
567
  mode: Mode;
568
+ session: ReviewSession;
569
+ editOrphanCount: number;
570
+ compose: ComposeState | null;
571
+ registerLine: (key: string, renderable: TextRenderable | null, meta: Omit<PlanLineRef, "renderable">) => void;
572
+ onLineActivate: (dispIdx: number) => void;
573
+ onEditRequest: () => void;
574
+ docScrollRef: React.RefObject<ScrollBoxRenderable | null>;
299
575
  }): React.ReactNode {
300
576
  const width = 76;
301
- return (
302
- <scrollbox style={{ flexGrow: 1, paddingLeft: 2, paddingTop: 1 }} focused={false}>
303
- {display.map((d, i) => {
304
- const isCursor = i === cursor;
305
- const gap = topGap(display[i - 1], d);
306
- if (d.kind === "code") {
307
- return (
308
- <CodeBlock
309
- key={i}
310
- block={d}
311
- isCursor={isCursor}
312
- gap={gap}
313
- annotated={(marks.get(i) ?? []).length > 0}
314
- />
315
- );
316
- }
317
- const blockMarks = [...(marks.get(i) ?? [])];
318
- if (mode.m === "span" && mode.span.dispIdx === i) {
319
- blockMarks.push({ start: mode.span.start, end: mode.span.end, role: "kspan" });
320
- }
321
- const runs = overlayMarks(blockRuns(d, true), blockMarks);
322
- const lines = wrapRuns(runs, width - marker(d).length);
323
- return (
324
- <box key={i} style={{ flexDirection: "column", marginTop: gap }}>
325
- {lines.map((line, li) => (
326
- <text key={li} bg={isCursor ? T.cursorBg : undefined}>
577
+ const children: React.ReactNode[] = [];
578
+ for (let i = 0; i < display.length; i++) {
579
+ const d = display[i]!;
580
+ const isCursor = i === cursor;
581
+ const gap = topGap(display[i - 1], d);
582
+ if (d.kind === "code") {
583
+ children.push(
584
+ <CodeBlock key={i} id={`plan-block-${i}`} block={d} isCursor={isCursor} gap={gap} annotated={(marks.get(i) ?? []).length > 0} />,
585
+ );
586
+ } else {
587
+ const blockMarks = [...(marks.get(i) ?? [])];
588
+ if (mode.m === "span" && mode.span.dispIdx === i) {
589
+ blockMarks.push({ start: mode.span.start, end: mode.span.end, role: "kspan" });
590
+ }
591
+ // the compose anchor stays painted selection-style while the box is open
592
+ if (mode.m === "compose" && mode.dispIdx === i) {
593
+ blockMarks.push({ start: mode.start, end: mode.end, role: "kspan" });
594
+ }
595
+ const runs = overlayMarks(blockRuns(d, true), blockMarks);
596
+ const lines = wrapRuns(runs, width - marker(d).length);
597
+ const prefixColumns = 2 + marker(d).length;
598
+ children.push(
599
+ <box key={i} id={`plan-block-${i}`} style={{ flexDirection: "column", marginTop: gap }}>
600
+ {lines.map((line, li) => {
601
+ const positioned = line.filter((run) => run.start !== null);
602
+ const lineStart = positioned.length ? positioned[0]!.start : null;
603
+ const lastPositioned = positioned[positioned.length - 1];
604
+ const textLength =
605
+ lineStart !== null && lastPositioned ? lastPositioned.start! + lastPositioned.text.length - lineStart : 0;
606
+ return (
607
+ <text
608
+ key={li}
609
+ bg={isCursor ? T.cursorBg : undefined}
610
+ selectable
611
+ selectionBg={T.accent}
612
+ selectionFg={T.accentInk}
613
+ ref={(renderable: TextRenderable | null) =>
614
+ registerLine(`${i}:${li}`, renderable, { dispIdx: i, lineIndex: li, lineStart, prefixColumns, textLength })
615
+ }
616
+ onMouseUp={() => onLineActivate(i)}
617
+ >
327
618
  <span fg={isCursor ? T.accent : T.textDim}>{li === 0 ? cursorGlyph(isCursor) : " "}</span>
328
619
  <span fg={T.textDim}>{li === 0 ? marker(d) : " ".repeat(marker(d).length)}</span>
329
620
  {line.map((r, ri) => (
@@ -333,20 +624,70 @@ function PlanPane({
333
624
  ))}
334
625
  {li === 0 && d.type !== "same" ? <span fg={tagColor(d)}> [{tagLabel(d)}]</span> : null}
335
626
  </text>
336
- ))}
337
- </box>
338
- );
339
- })}
340
- </scrollbox>
627
+ );
628
+ })}
629
+ </box>,
630
+ );
631
+ }
632
+ if (compose && compose.dispIdx === i) {
633
+ children.push(
634
+ <AnnotationCard
635
+ key={`compose-${i}`}
636
+ kind={compose.kind}
637
+ quote={compose.quote}
638
+ draft={{ text: compose.text, onInput: compose.onInput, onSave: compose.onSave, onCancel: compose.onCancel }}
639
+ />,
640
+ );
641
+ }
642
+ }
643
+ return (
644
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
645
+ <SheetHeader session={session} onEditRequest={onEditRequest} />
646
+ {editOrphanCount > 0 ? (
647
+ <box style={{ height: 1, backgroundColor: T.markCommentBg, paddingLeft: 2 }}>
648
+ <text fg={T.red}>
649
+ {editOrphanCount} annotation{editOrphanCount === 1 ? "" : "s"} no longer match - the passage was removed.
650
+ </text>
651
+ </box>
652
+ ) : null}
653
+ <scrollbox ref={docScrollRef} style={{ flexGrow: 1, paddingLeft: 2, paddingTop: 1 }} focused={false}>
654
+ {children}
655
+ </scrollbox>
656
+ </box>
657
+ );
658
+ }
659
+
660
+ /** Sheet chrome: submitted-by + revision delta left, the Edit word-button right. */
661
+ function SheetHeader({ session, onEditRequest }: { session: ReviewSession; onEditRequest: () => void }): React.ReactNode {
662
+ const revisionCount = session.revisions.length;
663
+ const previous = revisionCount > 1 ? session.revisions[revisionCount - 2] : undefined;
664
+ const delta = previous ? revisionDelta(previous.content, session.artifact.content) : null;
665
+ return (
666
+ <box style={{ height: 1, flexDirection: "row", paddingLeft: 2, paddingRight: 1 }}>
667
+ <text fg={T.textDim}>
668
+ submitted by <span fg={T.textMuted}>{session.artifact.meta.agent ?? "unknown"}</span> · revision {revisionCount}
669
+ {delta ? (
670
+ <span fg={T.green}>
671
+ {" "}· v{revisionCount - 1}→v{revisionCount} +{delta.added} -{delta.removed}
672
+ </span>
673
+ ) : null}
674
+ </text>
675
+ <box style={{ flexGrow: 1 }} />
676
+ <box onMouseUp={onEditRequest}>
677
+ <text fg={T.textDim}> Edit </text>
678
+ </box>
679
+ </box>
341
680
  );
342
681
  }
343
682
 
344
683
  function CodeBlock({
684
+ id,
345
685
  block,
346
686
  isCursor,
347
687
  gap,
348
688
  annotated,
349
689
  }: {
690
+ id: string;
350
691
  block: DisplayBlock;
351
692
  isCursor: boolean;
352
693
  gap: number;
@@ -367,7 +708,7 @@ function CodeBlock({
367
708
  }, [content, lang]);
368
709
  const lines: CodeToken[][] = tokens ?? content.split("\n").map((l) => [{ content: l }]);
369
710
  return (
370
- <box style={{ flexDirection: "column", marginTop: gap }}>
711
+ <box id={id} style={{ flexDirection: "column", marginTop: gap }}>
371
712
  <text>
372
713
  <span fg={isCursor ? T.accent : T.textDim}>{cursorGlyph(isCursor)}</span>
373
714
  <span fg={T.textDim}>{lang ?? "code"}</span>
@@ -411,47 +752,193 @@ function topGap(prev: DisplayBlock | undefined, cur: DisplayBlock): number {
411
752
  return tightPair ? 0 : 1;
412
753
  }
413
754
 
755
+ // ── the rail ──────────────────────────────────
756
+
757
+ interface CardEditState {
758
+ id: string;
759
+ text: string;
760
+ onInput: (text: string) => void;
761
+ onSave: () => void;
762
+ onCancel: () => void;
763
+ }
764
+
414
765
  function Rail({
415
766
  session,
416
- focusedAnn,
767
+ selectedId,
768
+ resolvedIds,
769
+ railTab,
417
770
  pendingCount,
771
+ cardEdit,
772
+ onTab,
773
+ onSelectCard,
774
+ onActivateCard,
775
+ railScrollRef,
418
776
  }: {
419
777
  session: ReviewSession;
420
- focusedAnn?: string;
778
+ selectedId?: string;
779
+ /** Ids whose anchor resolved; null = orphan display off (diff view). */
780
+ resolvedIds: Set<string> | null;
781
+ railTab: RailTab;
421
782
  pendingCount: number;
783
+ cardEdit: CardEditState | null;
784
+ onTab: (tab: RailTab) => void;
785
+ onSelectCard: (id: string) => void;
786
+ onActivateCard: (id: string) => void;
787
+ railScrollRef: React.RefObject<ScrollBoxRenderable | null>;
422
788
  }): React.ReactNode {
423
789
  return (
424
790
  <box style={{ width: 34, backgroundColor: T.panel, flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
425
- <text fg={T.textDim}>REVIEW ({pendingCount})</text>
426
- <text fg={T.textDim}>{session.workingCopy !== undefined ? "± plan edits → one diff" : " no direct edits"}</text>
791
+ <box style={{ height: 1, flexDirection: "row" }}>
792
+ <box onMouseUp={() => onTab("review")}>
793
+ <text fg={railTab === "review" ? T.accent : T.textDim}>Review ({pendingCount})</text>
794
+ </box>
795
+ <text fg={T.textDim}>{" "}</text>
796
+ <box onMouseUp={() => onTab("agent")}>
797
+ <text fg={railTab === "agent" ? T.accent : T.textDim}>Agent</text>
798
+ </box>
799
+ </box>
427
800
  <text> </text>
428
- {session.annotations.length === 0 ? (
429
- <text fg={T.textDim}>no annotations yet</text>
801
+ {railTab === "agent" ? (
802
+ <AgentTab session={session} />
430
803
  ) : (
431
- session.annotations.map((a) => <AnnCard key={a.id} a={a} focused={a.id === focusedAnn} />)
804
+ <>
805
+ {session.workingCopy !== undefined ? <text fg={T.textDim}>± plan edits → one diff</text> : null}
806
+ {session.annotations.length === 0 ? (
807
+ <text fg={T.textDim}>no annotations yet</text>
808
+ ) : (
809
+ <scrollbox ref={railScrollRef} style={{ flexGrow: 1 }} focused={false}>
810
+ {session.annotations.map((annotation) => (
811
+ <AnnotationCard
812
+ key={annotation.id}
813
+ kind={annotation.kind}
814
+ quote={annotation.anchor.quote}
815
+ saved={{
816
+ body: annotation.body,
817
+ selected: annotation.id === selectedId,
818
+ orphan: resolvedIds !== null && !resolvedIds.has(annotation.id),
819
+ blocking: annotationBlocking(annotation),
820
+ editing:
821
+ cardEdit && cardEdit.id === annotation.id
822
+ ? { text: cardEdit.text, onInput: cardEdit.onInput, onSave: cardEdit.onSave, onCancel: cardEdit.onCancel }
823
+ : null,
824
+ onMouseUp: () =>
825
+ annotation.id === selectedId ? onActivateCard(annotation.id) : onSelectCard(annotation.id),
826
+ }}
827
+ />
828
+ ))}
829
+ </scrollbox>
830
+ )}
831
+ <box style={{ flexGrow: 1 }} />
832
+ <text fg={session.status === "resolved" ? T.green : T.accent}>
833
+ {session.status === "resolved" ? `resolved: ${session.verdict!.kind.replace("_", " ")}` : `Submit review (${pendingCount}) ⏎`}
834
+ </text>
835
+ </>
432
836
  )}
433
- <box style={{ flexGrow: 1 }} />
434
- <text fg={session.status === "resolved" ? T.green : T.accent}>
435
- {session.status === "resolved" ? `resolved: ${session.verdict!.kind.replace("_", " ")}` : `Submit review (${pendingCount}) ⏎`}
436
- </text>
437
837
  </box>
438
838
  );
439
839
  }
440
840
 
441
- function AnnCard({ a, focused }: { a: Annotation; focused: boolean }): React.ReactNode {
442
- const color = a.kind === "suggestion" ? T.green : T.accent;
841
+ /** Agent tab: who submitted, where the session stands, which revision. */
842
+ function AgentTab({ session }: { session: ReviewSession }): React.ReactNode {
443
843
  return (
444
- <box style={{ flexDirection: "column", marginBottom: 1 }}>
445
- <text fg={focused ? T.text : color}>
446
- {focused ? "▸ " : " "}
447
- {a.kind.toUpperCase()}
844
+ <box style={{ flexDirection: "column", flexGrow: 1 }}>
845
+ <text fg={T.textMuted}>{session.artifact.meta.agent ?? "unknown"}</text>
846
+ <text fg={T.textDim}>status: {session.status}</text>
847
+ <text fg={T.textDim}>revision {session.revisions.length}</text>
848
+ </box>
849
+ );
850
+ }
851
+
852
+ /**
853
+ * The composer and the rail card are one component in two modes: draft props
854
+ * attached (bordered box inline in the document flow) or saved props attached
855
+ * (rail card, optionally editing its body in place). Compose, saved, and
856
+ * re-edit all share this rendering path.
857
+ */
858
+ function AnnotationCard({
859
+ kind,
860
+ quote,
861
+ draft,
862
+ saved,
863
+ }: {
864
+ kind: string;
865
+ quote: string;
866
+ draft?: { text: string; onInput: (text: string) => void; onSave: () => void; onCancel: () => void };
867
+ saved?: {
868
+ body: string;
869
+ selected: boolean;
870
+ orphan: boolean;
871
+ blocking: boolean;
872
+ editing: { text: string; onInput: (text: string) => void; onSave: () => void; onCancel: () => void } | null;
873
+ onMouseUp: () => void;
874
+ };
875
+ }): React.ReactNode {
876
+ const color = kind === "suggestion" ? T.green : T.accent;
877
+ if (draft) {
878
+ const verb = kind === "suggestion" ? "suggest replacement for" : "comment on";
879
+ return (
880
+ <box
881
+ style={{
882
+ height: composeBoxHeight(),
883
+ marginLeft: 2,
884
+ marginRight: 2,
885
+ border: true,
886
+ borderStyle: "rounded",
887
+ borderColor: color,
888
+ backgroundColor: T.elevated,
889
+ flexDirection: "column",
890
+ paddingLeft: 1,
891
+ }}
892
+ title={` ${verb} "${truncate(quote, 40)}" `}
893
+ >
894
+ <input focused value={draft.text} onInput={draft.onInput} placeholder="write a note..." />
895
+ <SaveCancelRow onSave={draft.onSave} onCancel={draft.onCancel} />
896
+ </box>
897
+ );
898
+ }
899
+ const card = saved!;
900
+ return (
901
+ <box
902
+ style={{ flexDirection: "column", marginBottom: 1, backgroundColor: card.selected ? T.elevated : undefined }}
903
+ onMouseUp={card.onMouseUp}
904
+ >
905
+ <text fg={card.selected ? T.text : color}>
906
+ {card.selected ? "▸ " : " "}
907
+ {kind.toUpperCase()}
908
+ {card.blocking ? <span fg={T.red}> · BLOCKING</span> : null}
909
+ <span fg={T.textDim}>{card.orphan ? " · ORPHANED" : " · pending"}</span>
448
910
  </text>
449
- <text fg={T.textDim}> “{truncate(a.anchor.quote, 26)}”</text>
450
- <text fg={T.textMuted}> {truncate(a.body, 28)}</text>
911
+ <text fg={T.textDim}> "{truncate(quote, 26)}"</text>
912
+ {card.editing ? (
913
+ <>
914
+ <input focused value={card.editing.text} onInput={card.editing.onInput} />
915
+ <SaveCancelRow onSave={card.editing.onSave} onCancel={card.editing.onCancel} />
916
+ </>
917
+ ) : (
918
+ <text fg={card.orphan ? T.textDim : T.textMuted}> {truncate(card.body, 28)}</text>
919
+ )}
451
920
  </box>
452
921
  );
453
922
  }
454
923
 
924
+ function SaveCancelRow({ onSave, onCancel }: { onSave: () => void; onCancel: () => void }): React.ReactNode {
925
+ return (
926
+ <box style={{ flexDirection: "row", height: 1 }}>
927
+ <box style={{ backgroundColor: T.accent, marginRight: 2 }} onMouseUp={onSave}>
928
+ <text fg={T.accentInk}> Save ⏎ </text>
929
+ </box>
930
+ <box onMouseUp={onCancel}>
931
+ <text fg={T.textDim}> Cancel esc </text>
932
+ </box>
933
+ </box>
934
+ );
935
+ }
936
+
937
+ /** Forward-compatible: open annotation kinds may carry a blocking flag. */
938
+ function annotationBlocking(annotation: Annotation): boolean {
939
+ return (annotation as Annotation & { blocking?: boolean }).blocking === true;
940
+ }
941
+
455
942
  function Inbox({ inbox, cursor }: { inbox: ReviewSession[]; cursor: number }): React.ReactNode {
456
943
  return (
457
944
  <box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: T.bg, padding: 1 }}>
@@ -473,6 +960,7 @@ function Inbox({ inbox, cursor }: { inbox: ReviewSession[]; cursor: number }): R
473
960
  );
474
961
  }
475
962
 
963
+ /** Bottom compose bar - the diff view still composes here. */
476
964
  function ComposeBar({
477
965
  mode,
478
966
  quote,