@cueloop/client 0.1.0-alpha.2 → 0.1.0-alpha.21

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 (92) hide show
  1. package/package.json +17 -11
  2. package/src/App.autoclose.test.tsx +109 -0
  3. package/src/App.diff.test.tsx +5 -18
  4. package/src/App.export.test.tsx +8 -13
  5. package/src/App.plan-review.test.tsx +293 -0
  6. package/src/App.readonly.test.tsx +6 -21
  7. package/src/App.rendering.test.tsx +104 -0
  8. package/src/App.review-panel.test.tsx +97 -0
  9. package/src/App.submit-confirm.test.tsx +165 -0
  10. package/src/App.test.tsx +20 -58
  11. package/src/App.tsx +0 -0
  12. package/src/App.walk.test.tsx +208 -0
  13. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  14. package/src/components/AnnotationCard.stories.tsx +75 -0
  15. package/src/components/AnnotationCard.tsx +189 -0
  16. package/src/components/Breadcrumb.stories.tsx +19 -0
  17. package/src/components/Breadcrumb.tsx +38 -0
  18. package/src/components/CodeBlock.stories.tsx +25 -0
  19. package/src/components/CodeBlock.tsx +68 -0
  20. package/src/components/CompletionOverlay.stories.tsx +23 -0
  21. package/src/components/CompletionOverlay.tsx +49 -0
  22. package/src/components/ComposeBar.stories.tsx +18 -0
  23. package/src/components/ComposeBar.tsx +54 -0
  24. package/src/components/ConfirmCard.stories.tsx +37 -0
  25. package/src/components/ConfirmCard.tsx +117 -0
  26. package/src/components/DiffSheet.stories.tsx +44 -0
  27. package/src/components/DiffSheet.tsx +243 -0
  28. package/src/components/InboxList.stories.tsx +18 -0
  29. package/src/components/InboxList.tsx +42 -0
  30. package/src/components/PlanSheet.stories.tsx +81 -0
  31. package/src/components/PlanSheet.tsx +309 -0
  32. package/src/components/ReviewPanel.stories.tsx +86 -0
  33. package/src/components/ReviewPanel.tsx +116 -0
  34. package/src/components/ReviewRail.stories.tsx +103 -0
  35. package/src/components/ReviewRail.tsx +171 -0
  36. package/src/components/SettingsDialog.stories.tsx +63 -0
  37. package/src/components/SettingsDialog.tsx +132 -0
  38. package/src/components/SettingsRows.stories.tsx +18 -0
  39. package/src/components/SettingsRows.tsx +92 -0
  40. package/src/components/WalkWizard.stories.tsx +89 -0
  41. package/src/components/WalkWizard.tsx +146 -0
  42. package/src/components/__snapshots__/stories.test.tsx.snap +1459 -0
  43. package/src/components/primitives/Button.stories.tsx +32 -0
  44. package/src/components/primitives/Button.tsx +44 -0
  45. package/src/components/primitives/Card.stories.tsx +25 -0
  46. package/src/components/primitives/Card.tsx +59 -0
  47. package/src/components/primitives/Dialog.stories.tsx +24 -0
  48. package/src/components/primitives/Dialog.tsx +53 -0
  49. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  50. package/src/components/primitives/StatusBar.tsx +22 -0
  51. package/src/components/primitives/Tabs.stories.tsx +29 -0
  52. package/src/components/primitives/Tabs.tsx +97 -0
  53. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  54. package/src/components/primitives/Toolbar.tsx +14 -0
  55. package/src/components/primitives/frame.test.ts +12 -0
  56. package/src/components/primitives/frame.ts +14 -0
  57. package/src/components/stories-app.tsx +95 -0
  58. package/src/components/stories.test.tsx +51 -0
  59. package/src/components/story-fixtures.ts +90 -0
  60. package/src/components/story.ts +66 -0
  61. package/src/components/syntax-highlight.ts +57 -0
  62. package/src/components/theme-context.stories.tsx +41 -0
  63. package/src/components/theme-context.tsx +30 -0
  64. package/src/components/truncate-text.ts +6 -0
  65. package/src/config.test.ts +105 -30
  66. package/src/config.ts +91 -16
  67. package/src/editor.test.ts +110 -0
  68. package/src/editor.ts +101 -13
  69. package/src/herdr.test.ts +37 -0
  70. package/src/herdr.ts +21 -0
  71. package/src/integrations.test.ts +29 -0
  72. package/src/integrations.ts +28 -0
  73. package/src/key-bindings.test.ts +120 -0
  74. package/src/key-bindings.ts +353 -0
  75. package/src/keymap.test.ts +347 -0
  76. package/src/keymap.ts +229 -0
  77. package/src/review-panel.test.ts +111 -0
  78. package/src/review-panel.ts +76 -0
  79. package/src/run.ts +9 -5
  80. package/src/serve.test.ts +6 -6
  81. package/src/serve.ts +7 -7
  82. package/src/session-controller.ts +512 -0
  83. package/src/test-support.ts +87 -0
  84. package/src/theme.ts +23 -1
  85. package/src/view-diff.test.ts +8 -8
  86. package/src/view-diff.ts +22 -22
  87. package/src/view-plan.test.ts +179 -0
  88. package/src/view-plan.ts +362 -0
  89. package/src/walk.test.ts +110 -0
  90. package/src/walk.ts +65 -0
  91. package/src/view.test.ts +0 -157
  92. package/src/view.ts +0 -0
@@ -0,0 +1,81 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { PlanSheet } from "./PlanSheet";
5
+ import { fixtureDisplay, fixtureMarks, fixturePlanSession } from "./story-fixtures";
6
+
7
+ export const meta: StoryMeta = { title: "PlanSheet" };
8
+
9
+ const callbacks = { onLineActivate: () => {}, onEditRequest: () => {} };
10
+
11
+ export const AnnotatedPlan: Story = {
12
+ render: () => (
13
+ <PlanSheet
14
+ session={fixturePlanSession()}
15
+ display={fixtureDisplay()}
16
+ marks={fixtureMarks()}
17
+ cursor={2}
18
+ activeSpan={null}
19
+ compose={null}
20
+ editOrphanCount={0}
21
+ {...callbacks}
22
+ />
23
+ ),
24
+ expectedColors: [DARK.markCommentBg, DARK.markSuggestionBg, DARK.cursorBg],
25
+ size: { width: 90, height: 28 },
26
+ };
27
+
28
+ export const SpanSelection: Story = {
29
+ render: () => (
30
+ <PlanSheet
31
+ session={fixturePlanSession({ annotations: [] })}
32
+ display={fixtureDisplay()}
33
+ marks={new Map()}
34
+ cursor={2}
35
+ activeSpan={{ displayIndex: 2, start: 0, end: 10 }}
36
+ compose={null}
37
+ editOrphanCount={0}
38
+ {...callbacks}
39
+ />
40
+ ),
41
+ expectedColors: [DARK.accent],
42
+ size: { width: 90, height: 28 },
43
+ };
44
+
45
+ export const InlineCompose: Story = {
46
+ render: () => (
47
+ <PlanSheet
48
+ session={fixturePlanSession({ annotations: [] })}
49
+ display={fixtureDisplay()}
50
+ marks={new Map()}
51
+ cursor={2}
52
+ activeSpan={{ displayIndex: 2, start: 0, end: 10 }}
53
+ compose={{
54
+ kind: "comment",
55
+ displayIndex: 2,
56
+ quote: "The daemon",
57
+ draft: { text: "Which daemon?", onInput: () => {}, onSave: () => {}, onCancel: () => {} },
58
+ }}
59
+ editOrphanCount={0}
60
+ {...callbacks}
61
+ />
62
+ ),
63
+ size: { width: 90, height: 30 },
64
+ };
65
+
66
+ export const OrphanBanner: Story = {
67
+ render: () => (
68
+ <PlanSheet
69
+ session={fixturePlanSession()}
70
+ display={fixtureDisplay()}
71
+ marks={new Map()}
72
+ cursor={0}
73
+ activeSpan={null}
74
+ compose={null}
75
+ editOrphanCount={1}
76
+ {...callbacks}
77
+ />
78
+ ),
79
+ expectedColors: [DARK.red],
80
+ size: { width: 90, height: 28 },
81
+ };
@@ -0,0 +1,309 @@
1
+ /**
2
+ * The plan document sheet: header chrome, the reconciliation banner, and the
3
+ * scrollable block flow. Each prose block renders as one natively wrapped
4
+ * text (wrapMode "word") beside a marker gutter, so wrapping is the
5
+ * renderer's job; the sheet keeps the rendered/work offset mapping per block
6
+ * and exposes an imperative selection surface - the keyboard span drives the
7
+ * native selection, and a mouse drag reads back as an exact work-text range,
8
+ * which keeps quote anchors char-precise.
9
+ */
10
+
11
+ import React, { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
12
+ import { useRenderer } from "@opentui/react";
13
+ import type { ScrollBoxRenderable, TextRenderable } from "@opentui/core";
14
+ import type { ReviewSession } from "@cueloop/schema";
15
+ import {
16
+ blockRuns,
17
+ displayText,
18
+ overlayMarks,
19
+ renderedOffsetFor,
20
+ revisionDelta,
21
+ workRangeForRendered,
22
+ type DisplayBlock,
23
+ type Mark,
24
+ type SpanState,
25
+ type StyleRun,
26
+ } from "../view-plan";
27
+ import type { Theme } from "../theme";
28
+ import { useComponentTheme } from "./theme-context";
29
+ import { CodeBlock } from "./CodeBlock";
30
+ import { AnnotationCard, type AnnotationDraft } from "./AnnotationCard";
31
+ import { Button } from "./primitives/Button";
32
+ import { Toolbar } from "./primitives/Toolbar";
33
+
34
+ export interface PlanSelection {
35
+ displayIndex: number;
36
+ start: number;
37
+ end: number;
38
+ }
39
+
40
+ export interface PlanSheetHandle {
41
+ /** Work-text range of the current native (mouse) selection, if any. */
42
+ readSelection(): PlanSelection | null;
43
+ /** Anchor/extend the renderer's native selection from keyboard span offsets. */
44
+ driveSpanSelection(span: SpanState): void;
45
+ clearSelection(): void;
46
+ revealBlock(displayIndex: number): void;
47
+ }
48
+
49
+ export interface PlanComposeState {
50
+ kind: "comment" | "suggestion";
51
+ displayIndex: number;
52
+ quote: string;
53
+ draft: AnnotationDraft;
54
+ }
55
+
56
+ export interface PlanSheetProps {
57
+ session: ReviewSession;
58
+ display: DisplayBlock[];
59
+ marks: Map<number, Mark[]>;
60
+ cursor: number;
61
+ /** Extra selection-style paint on one block (keyboard span or compose anchor). */
62
+ activeSpan: { displayIndex: number; start: number; end: number } | null;
63
+ compose: PlanComposeState | null;
64
+ editOrphanCount: number;
65
+ onLineActivate: (displayIndex: number) => void;
66
+ onEditRequest: () => void;
67
+ theme?: Theme;
68
+ }
69
+
70
+ interface BlockRef {
71
+ renderable: TextRenderable;
72
+ runs: StyleRun[];
73
+ }
74
+
75
+ /** Screen position of a rendered-text offset inside a wrapped text. */
76
+ function positionOfRenderedOffset(renderable: TextRenderable, renderedOffset: number): { x: number; y: number } {
77
+ const info = renderable.lineInfo;
78
+ let lineIndex = 0;
79
+ for (let index = 0; index < info.lineStartCols.length; index++) {
80
+ if (info.lineStartCols[index]! <= renderedOffset) lineIndex = index;
81
+ else break;
82
+ }
83
+ return {
84
+ x: renderable.x + renderedOffset - (info.lineStartCols[lineIndex] ?? 0),
85
+ y: renderable.y + lineIndex,
86
+ };
87
+ }
88
+
89
+ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function PlanSheet(
90
+ {
91
+ session,
92
+ display,
93
+ marks,
94
+ cursor,
95
+ activeSpan,
96
+ compose,
97
+ editOrphanCount,
98
+ onLineActivate,
99
+ onEditRequest,
100
+ theme,
101
+ }: PlanSheetProps,
102
+ handleRef,
103
+ ): React.ReactNode {
104
+ const tokens = useComponentTheme(theme);
105
+ const renderer = useRenderer();
106
+ const blockRefs = useRef(new Map<number, BlockRef>());
107
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null);
108
+
109
+ useImperativeHandle(handleRef, () => ({
110
+ readSelection: (): PlanSelection | null => {
111
+ if (!renderer?.hasSelection) return null;
112
+ const ordered = [...blockRefs.current.entries()].sort(([a], [b]) => a - b);
113
+ for (const [displayIndex, blockRef] of ordered) {
114
+ const selection = blockRef.renderable.getSelection();
115
+ if (!selection || selection.end <= selection.start) continue;
116
+ const range = workRangeForRendered(blockRef.runs, selection.start, selection.end);
117
+ if (range) return { displayIndex, ...range };
118
+ }
119
+ return null;
120
+ },
121
+ driveSpanSelection: (span: SpanState): void => {
122
+ if (!renderer) return;
123
+ const blockRef = blockRefs.current.get(span.displayIndex);
124
+ if (!blockRef) return;
125
+ const renderedStart = renderedOffsetFor(blockRef.runs, span.start);
126
+ const renderedEnd = renderedOffsetFor(blockRef.runs, span.end - 1);
127
+ if (renderedStart === null || renderedEnd === null) return;
128
+ const startPosition = positionOfRenderedOffset(blockRef.renderable, renderedStart);
129
+ const endPosition = positionOfRenderedOffset(blockRef.renderable, renderedEnd);
130
+ renderer.startSelection(blockRef.renderable, startPosition.x, startPosition.y);
131
+ renderer.updateSelection(blockRef.renderable, endPosition.x + 1, endPosition.y);
132
+ },
133
+ clearSelection: (): void => {
134
+ renderer?.clearSelection();
135
+ },
136
+ revealBlock: (displayIndex: number): void => {
137
+ try {
138
+ scrollRef.current?.scrollChildIntoView(`plan-block-${displayIndex}`);
139
+ } catch {
140
+ // reveal is best-effort; selection state is already correct
141
+ }
142
+ },
143
+ }));
144
+
145
+ const registerBlock = (displayIndex: number, renderable: TextRenderable | null, runs: StyleRun[]): void => {
146
+ if (renderable) blockRefs.current.set(displayIndex, { renderable, runs });
147
+ else blockRefs.current.delete(displayIndex);
148
+ };
149
+ // stale refs must not survive a shrinking display list
150
+ useEffect(() => {
151
+ for (const displayIndex of blockRefs.current.keys()) {
152
+ if (displayIndex >= display.length) blockRefs.current.delete(displayIndex);
153
+ }
154
+ }, [display]);
155
+
156
+ const children: React.ReactNode[] = [];
157
+ for (let displayIndex = 0; displayIndex < display.length; displayIndex++) {
158
+ const block = display[displayIndex]!;
159
+ const isCursor = displayIndex === cursor;
160
+ const gap = topGap(display[displayIndex - 1], block);
161
+ if (block.kind === "code") {
162
+ children.push(
163
+ <CodeBlock
164
+ key={displayIndex}
165
+ id={`plan-block-${displayIndex}`}
166
+ language={(block.work ?? block.base)?.lang}
167
+ content={displayText(block)}
168
+ isCursor={isCursor}
169
+ marginTop={gap}
170
+ isAnnotated={(marks.get(displayIndex) ?? []).length > 0}
171
+ changeTag={block.type !== "same" ? tagLabel(block) : undefined}
172
+ theme={theme}
173
+ />,
174
+ );
175
+ } else {
176
+ const blockMarks = [...(marks.get(displayIndex) ?? [])];
177
+ if (activeSpan && activeSpan.displayIndex === displayIndex) {
178
+ blockMarks.push({ start: activeSpan.start, end: activeSpan.end, role: "kspan" });
179
+ }
180
+ const runs = overlayMarks(blockRuns(block, true), blockMarks);
181
+ // the change tag participates in the rendered text but carries no offsets
182
+ const mappedRuns: StyleRun[] =
183
+ block.type !== "same" ? [...runs, { text: ` [${tagLabel(block)}]`, role: "plain", start: null }] : runs;
184
+ children.push(
185
+ <box key={displayIndex} id={`plan-block-${displayIndex}`} style={{ flexDirection: "row", marginTop: gap }}>
186
+ <text selectable={false}>
187
+ <span fg={isCursor ? tokens.accent : tokens.textDim}>{isCursor ? "▎ " : " "}</span>
188
+ <span fg={tokens.textDim}>{marker(block)}</span>
189
+ </text>
190
+ <text
191
+ bg={isCursor ? tokens.cursorBg : undefined}
192
+ selectable
193
+ selectionBg={tokens.accent}
194
+ selectionFg={tokens.accentInk}
195
+ style={{ wrapMode: "word", flexGrow: 1, flexShrink: 1 }}
196
+ ref={(renderable: TextRenderable | null) => registerBlock(displayIndex, renderable, mappedRuns)}
197
+ onMouseUp={() => onLineActivate(displayIndex)}
198
+ >
199
+ {runs.map((run, runIndex) => (
200
+ <span key={runIndex} {...runStyle(run, block, tokens)}>
201
+ {run.text}
202
+ </span>
203
+ ))}
204
+ {block.type !== "same" ? <span fg={tagColor(block, tokens)}> [{tagLabel(block)}]</span> : null}
205
+ </text>
206
+ </box>,
207
+ );
208
+ }
209
+ if (compose && compose.displayIndex === displayIndex) {
210
+ children.push(
211
+ <AnnotationCard key={`compose-${displayIndex}`} kind={compose.kind} quote={compose.quote} draft={compose.draft} theme={theme} />,
212
+ );
213
+ }
214
+ }
215
+
216
+ return (
217
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
218
+ <SheetHeader session={session} onEditRequest={onEditRequest} theme={theme} />
219
+ {editOrphanCount > 0 ? (
220
+ <box style={{ height: 1, backgroundColor: tokens.markCommentBg, paddingLeft: 2 }}>
221
+ <text fg={tokens.red}>
222
+ {editOrphanCount} annotation{editOrphanCount === 1 ? "" : "s"} no longer match - the passage was removed.
223
+ </text>
224
+ </box>
225
+ ) : null}
226
+ <scrollbox ref={scrollRef} style={{ flexGrow: 1, paddingLeft: 2, paddingTop: 1 }} focused={false}>
227
+ {children}
228
+ </scrollbox>
229
+ </box>
230
+ );
231
+ });
232
+
233
+ /** Sheet chrome: submitted-by + revision delta left, the Edit word-button right. */
234
+ function SheetHeader({
235
+ session,
236
+ onEditRequest,
237
+ theme,
238
+ }: {
239
+ session: ReviewSession;
240
+ onEditRequest: () => void;
241
+ theme?: Theme;
242
+ }): React.ReactNode {
243
+ const tokens = useComponentTheme(theme);
244
+ const revisionCount = session.revisions.length;
245
+ const previous = revisionCount > 1 ? session.revisions[revisionCount - 2] : undefined;
246
+ const delta = previous ? revisionDelta(previous.content, session.artifact.content) : null;
247
+ return (
248
+ <box style={{ height: 1, flexDirection: "row", paddingLeft: 2, paddingRight: 1 }}>
249
+ <text fg={tokens.textDim}>
250
+ submitted by <span fg={tokens.textMuted}>{session.artifact.meta.agent ?? "unknown"}</span> · revision {revisionCount}
251
+ {delta ? (
252
+ <span fg={tokens.green}>
253
+ {" "}· v{revisionCount - 1}→v{revisionCount} +{delta.added} -{delta.removed}
254
+ </span>
255
+ ) : null}
256
+ </text>
257
+ <box style={{ flexGrow: 1 }} />
258
+ <Toolbar>
259
+ <Button onPress={onEditRequest} theme={theme}>
260
+ {" Edit "}
261
+ </Button>
262
+ </Toolbar>
263
+ </box>
264
+ );
265
+ }
266
+
267
+ /** Vertical rhythm: gaps live ABOVE blocks so boundaries never collapse. */
268
+ function topGap(previous: DisplayBlock | undefined, current: DisplayBlock): number {
269
+ if (!previous) return 0;
270
+ const tightPair =
271
+ (current.kind === "li" && previous.kind === "li") || (current.kind === "oli" && previous.kind === "oli");
272
+ return tightPair ? 0 : 1;
273
+ }
274
+
275
+ function marker(block: DisplayBlock): string {
276
+ if (block.kind === "li") return "- ";
277
+ if (block.kind === "oli") return `${block.orderedItemNumber ?? 1}. `;
278
+ if (block.kind === "quote") return "▏ ";
279
+ return "";
280
+ }
281
+
282
+ function tagLabel(block: DisplayBlock): "cut" | "new" | "edited" {
283
+ return block.type === "del" ? "cut" : block.type === "add" ? "new" : "edited";
284
+ }
285
+
286
+ function tagColor(block: DisplayBlock, tokens: Theme): string {
287
+ return block.type === "del" ? tokens.red : block.type === "add" ? tokens.green : tokens.accent;
288
+ }
289
+
290
+ function runStyle(run: StyleRun, block: DisplayBlock, tokens: Theme): { fg?: string; bg?: string } {
291
+ const headingFg = block.kind === "h1" ? tokens.text : block.kind === "h2" || block.kind === "h3" ? tokens.accent : undefined;
292
+ const struck = block.type === "del";
293
+ switch (run.role) {
294
+ case "ins":
295
+ return { fg: tokens.insFg };
296
+ case "del":
297
+ return { fg: tokens.delFg };
298
+ case "mark-comment":
299
+ return { fg: tokens.text, bg: tokens.markCommentBg };
300
+ case "mark-suggestion":
301
+ return { fg: tokens.text, bg: tokens.markSuggestionBg };
302
+ case "mark-focus":
303
+ return { fg: tokens.accentInk, bg: tokens.accent };
304
+ case "kspan":
305
+ return { fg: tokens.accentInk, bg: tokens.accent };
306
+ default:
307
+ return { fg: struck ? tokens.red : (headingFg ?? tokens.text) };
308
+ }
309
+ }
@@ -0,0 +1,86 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import { REVIEW_MAX_WIDTH, REVIEW_MIN_WIDTH } from "../review-panel";
4
+ import type { Story, StoryMeta } from "./story";
5
+ import { ReviewPanel } from "./ReviewPanel";
6
+ import { FIXTURE_ANNOTATIONS, fixturePlanSession } from "./story-fixtures";
7
+
8
+ export const meta: StoryMeta = { title: "ReviewPanel" };
9
+
10
+ const rail = {
11
+ session: fixturePlanSession(),
12
+ selectedId: FIXTURE_ANNOTATIONS[0]!.id,
13
+ resolvedIds: new Set(FIXTURE_ANNOTATIONS.map((annotation) => annotation.id)),
14
+ railTab: "review" as const,
15
+ pendingCount: 2,
16
+ cardEdit: null,
17
+ submitConfirm: null,
18
+ onTabChange: () => {},
19
+ onSelectCard: () => {},
20
+ onActivateCard: () => {},
21
+ onSubmitRequest: () => {},
22
+ };
23
+
24
+ const panelCallbacks = { onDividerGrab: () => {}, onToggle: () => {} };
25
+
26
+ /** The plan area to the left of the divider, so the layout reads in context. */
27
+ function PanelFrame({ children }: { children: React.ReactNode }): React.ReactNode {
28
+ return (
29
+ <box style={{ flexDirection: "row", width: "100%", height: "100%", backgroundColor: DARK.bg }}>
30
+ <box style={{ flexGrow: 1, paddingLeft: 1 }}>
31
+ <text fg={DARK.textMuted}>The plan takes the width the panel hands back.</text>
32
+ </box>
33
+ {children}
34
+ </box>
35
+ );
36
+ }
37
+
38
+ export const ExpandedNarrow: Story = {
39
+ render: () => (
40
+ <PanelFrame>
41
+ <ReviewPanel mode="expanded" width={REVIEW_MIN_WIDTH} height={24} dragging={false} rail={rail} {...panelCallbacks} />
42
+ </PanelFrame>
43
+ ),
44
+ expectedColors: [DARK.panel, DARK.border],
45
+ size: { width: 80, height: 24 },
46
+ };
47
+
48
+ export const ExpandedWide: Story = {
49
+ render: () => (
50
+ <PanelFrame>
51
+ <ReviewPanel mode="expanded" width={REVIEW_MAX_WIDTH} height={24} dragging={false} rail={rail} {...panelCallbacks} />
52
+ </PanelFrame>
53
+ ),
54
+ expectedColors: [DARK.panel, DARK.green],
55
+ size: { width: 90, height: 24 },
56
+ };
57
+
58
+ /** Dragging accents the divider glyph column. */
59
+ export const DraggingDivider: Story = {
60
+ render: () => (
61
+ <PanelFrame>
62
+ <ReviewPanel mode="expanded" width={34} height={24} dragging={true} rail={rail} {...panelCallbacks} />
63
+ </PanelFrame>
64
+ ),
65
+ expectedColors: [DARK.accent],
66
+ size: { width: 80, height: 24 },
67
+ };
68
+
69
+ export const Compact: Story = {
70
+ render: () => (
71
+ <PanelFrame>
72
+ <ReviewPanel mode="compact" width={34} height={24} dragging={false} rail={rail} {...panelCallbacks} />
73
+ </PanelFrame>
74
+ ),
75
+ expectedColors: [DARK.accent, DARK.green],
76
+ size: { width: 60, height: 24 },
77
+ };
78
+
79
+ export const Hidden: Story = {
80
+ render: () => (
81
+ <PanelFrame>
82
+ <ReviewPanel mode="hidden" width={34} height={24} dragging={false} rail={rail} {...panelCallbacks} />
83
+ </PanelFrame>
84
+ ),
85
+ size: { width: 60, height: 24 },
86
+ };
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The review surface's outer shell: the grabbable divider plus the rail in one
3
+ * of three modes - expanded (the full ReviewRail, resizable), compact (a narrow
4
+ * count + dots strip that hands width back to the plan), or hidden (nothing at
5
+ * all, no leftover tab). The keybinding cycles all three; the clickable chevron
6
+ * toggles expanded and compact only. App owns the width/mode state and the drag
7
+ * on the container; this file only reads them. The geometry (width clamp, the
8
+ * divider glyph column, the cycle) lives in review-panel.ts so this stays
9
+ * declarative.
10
+ */
11
+
12
+ import React from "react";
13
+ import type { Annotation } from "@cueloop/schema";
14
+ import type { Theme } from "../theme";
15
+ import { useComponentTheme } from "./theme-context";
16
+ import { ReviewRail, type ReviewRailHandle, type ReviewRailProps } from "./ReviewRail";
17
+ import { REVIEW_COMPACT_WIDTH, reviewRowsToDivider, type ReviewPanelMode } from "../review-panel";
18
+
19
+ export interface ReviewPanelProps {
20
+ mode: ReviewPanelMode;
21
+ /** Expanded-rail width in columns (already clamped by the app). */
22
+ width: number;
23
+ /** Rows for the divider glyph column - the height of the plan/rail row. */
24
+ height: number;
25
+ /** Accent the divider while a drag is live. */
26
+ dragging: boolean;
27
+ /** Arm a divider drag; the app tracks the drag itself on the container. */
28
+ onDividerGrab: () => void;
29
+ /** Chevron click: expanded <-> compact (never hidden). */
30
+ onToggle: () => void;
31
+ /** The expanded ReviewRail's props; width and onCollapse are supplied here. */
32
+ rail: Omit<ReviewRailProps, "width" | "onCollapse" | "theme">;
33
+ railRef?: React.Ref<ReviewRailHandle>;
34
+ theme?: Theme;
35
+ }
36
+
37
+ /** A single-column stack of `│`, accent while dragging. Grabbing arms a drag. */
38
+ function ReviewDivider({
39
+ dragging,
40
+ rows,
41
+ onGrab,
42
+ theme,
43
+ }: {
44
+ dragging: boolean;
45
+ rows: number;
46
+ onGrab: () => void;
47
+ theme?: Theme;
48
+ }): React.ReactNode {
49
+ const tokens = useComponentTheme(theme);
50
+ return (
51
+ <box style={{ width: 1 }} onMouseDown={onGrab}>
52
+ <text fg={dragging ? tokens.accent : tokens.border}>{reviewRowsToDivider(rows)}</text>
53
+ </box>
54
+ );
55
+ }
56
+
57
+ /** The compact strip: the card count, one kind-colored dot per card, and the
58
+ * `«` chevron left-bound to match the expanded `»` gap. */
59
+ function CompactRail({
60
+ annotations,
61
+ onExpand,
62
+ theme,
63
+ }: {
64
+ annotations: Annotation[];
65
+ onExpand: () => void;
66
+ theme?: Theme;
67
+ }): React.ReactNode {
68
+ const tokens = useComponentTheme(theme);
69
+ const dotColor = (annotation: Annotation): string =>
70
+ annotation.kind === "suggestion" ? tokens.green : tokens.accent;
71
+ return (
72
+ <box
73
+ style={{ width: REVIEW_COMPACT_WIDTH, backgroundColor: tokens.panel, flexDirection: "column", paddingTop: 1 }}
74
+ >
75
+ <box style={{ alignItems: "center" }}>
76
+ <text fg={tokens.accent}>{String(annotations.length)}</text>
77
+ </box>
78
+ <box style={{ height: 1 }} />
79
+ <box style={{ flexDirection: "column", alignItems: "center" }}>
80
+ {annotations.map((annotation) => (
81
+ <text key={annotation.id} fg={dotColor(annotation)}>
82
+ ●
83
+ </text>
84
+ ))}
85
+ </box>
86
+ <box style={{ flexGrow: 1 }} />
87
+ <box style={{ paddingLeft: 1 }} onMouseUp={onExpand}>
88
+ <text fg={tokens.textDim}>«</text>
89
+ </box>
90
+ </box>
91
+ );
92
+ }
93
+
94
+ export function ReviewPanel({
95
+ mode,
96
+ width,
97
+ height,
98
+ dragging,
99
+ onDividerGrab,
100
+ onToggle,
101
+ rail,
102
+ railRef,
103
+ theme,
104
+ }: ReviewPanelProps): React.ReactNode {
105
+ if (mode === "hidden") return null;
106
+ return (
107
+ <>
108
+ <ReviewDivider dragging={dragging} rows={height} onGrab={onDividerGrab} theme={theme} />
109
+ {mode === "expanded" ? (
110
+ <ReviewRail ref={railRef} {...rail} width={width} onCollapse={onToggle} theme={theme} />
111
+ ) : (
112
+ <CompactRail annotations={rail.session.annotations} onExpand={onToggle} theme={theme} />
113
+ )}
114
+ </>
115
+ );
116
+ }
@@ -0,0 +1,103 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { ReviewRail } from "./ReviewRail";
5
+ import { FIXTURE_ANNOTATIONS, fixturePlanSession } from "./story-fixtures";
6
+
7
+ export const meta: StoryMeta = { title: "ReviewRail" };
8
+
9
+ const callbacks = {
10
+ onTabChange: () => {},
11
+ onSelectCard: () => {},
12
+ onActivateCard: () => {},
13
+ onSubmitRequest: () => {},
14
+ };
15
+
16
+ /** The rail expects a height-constrained parent, like the app's main row. */
17
+ function RailFrame({ children }: { children: React.ReactNode }): React.ReactNode {
18
+ return <box style={{ flexDirection: "row", width: "100%", height: "100%" }}>{children}</box>;
19
+ }
20
+
21
+ export const AnnotationStack: Story = {
22
+ render: () => (
23
+ <RailFrame>
24
+ <ReviewRail
25
+ session={fixturePlanSession()}
26
+ selectedId={FIXTURE_ANNOTATIONS[0]!.id}
27
+ resolvedIds={new Set(FIXTURE_ANNOTATIONS.map((annotation) => annotation.id))}
28
+ railTab="review"
29
+ pendingCount={2}
30
+ cardEdit={null}
31
+ submitConfirm={null}
32
+ {...callbacks}
33
+ />
34
+ </RailFrame>
35
+ ),
36
+ expectedColors: [DARK.panel, DARK.green],
37
+ size: { width: 40, height: 24 },
38
+ };
39
+
40
+ export const AgentTab: Story = {
41
+ render: () => (
42
+ <RailFrame>
43
+ <ReviewRail
44
+ session={fixturePlanSession()}
45
+ resolvedIds={new Set()}
46
+ railTab="agent"
47
+ pendingCount={2}
48
+ cardEdit={null}
49
+ submitConfirm={null}
50
+ {...callbacks}
51
+ />
52
+ </RailFrame>
53
+ ),
54
+ size: { width: 40, height: 24 },
55
+ };
56
+
57
+ export const SubmitConfirmOpen: Story = {
58
+ render: () => (
59
+ <RailFrame>
60
+ <ReviewRail
61
+ session={fixturePlanSession()}
62
+ resolvedIds={new Set()}
63
+ railTab="review"
64
+ pendingCount={2}
65
+ cardEdit={null}
66
+ submitConfirm={{
67
+ verdict: "request_changes",
68
+ summary: "",
69
+ annotationCount: 2,
70
+ blockingCount: 0,
71
+ onInput: () => {},
72
+ onSelectVerdict: () => {},
73
+ onSubmit: () => {},
74
+ onCancel: () => {},
75
+ }}
76
+ {...callbacks}
77
+ />
78
+ </RailFrame>
79
+ ),
80
+ expectedColors: [DARK.red],
81
+ size: { width: 40, height: 30 },
82
+ };
83
+
84
+ export const Resolved: Story = {
85
+ render: () => (
86
+ <RailFrame>
87
+ <ReviewRail
88
+ session={fixturePlanSession({
89
+ status: "resolved",
90
+ verdict: { kind: "approve", summary: "", feedback: "", resolvedAt: "2026-01-01T00:00:00Z" },
91
+ })}
92
+ resolvedIds={new Set()}
93
+ railTab="review"
94
+ pendingCount={0}
95
+ cardEdit={null}
96
+ submitConfirm={null}
97
+ {...callbacks}
98
+ />
99
+ </RailFrame>
100
+ ),
101
+ expectedColors: [DARK.green],
102
+ size: { width: 40, height: 24 },
103
+ };