@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/package.json +5 -5
- package/src/App.plan-review.test.tsx +259 -0
- package/src/App.test.tsx +2 -2
- package/src/App.tsx +559 -71
- package/src/keymap.test.ts +10 -3
- package/src/keymap.ts +8 -2
- package/src/run.ts +3 -1
- package/src/session-controller.ts +58 -5
- package/src/view.ts +57 -5
package/src/App.tsx
CHANGED
|
@@ -1,23 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The cueloop TUI (#22):
|
|
3
|
-
*
|
|
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,
|
|
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,
|
|
94
|
-
[session, display,
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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={
|
|
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
|
-
:
|
|
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,
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
426
|
-
|
|
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
|
-
{
|
|
429
|
-
<
|
|
801
|
+
{railTab === "agent" ? (
|
|
802
|
+
<AgentTab session={session} />
|
|
430
803
|
) : (
|
|
431
|
-
|
|
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
|
-
|
|
442
|
-
|
|
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",
|
|
445
|
-
<text fg={
|
|
446
|
-
|
|
447
|
-
|
|
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}>
|
|
450
|
-
|
|
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,
|