@cueloop/client 0.1.0-alpha.11 → 0.1.0-alpha.12
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 +7 -7
- package/src/App.autoclose.test.tsx +24 -35
- package/src/App.diff.test.tsx +5 -18
- package/src/App.export.test.tsx +8 -21
- package/src/App.plan-review.test.tsx +21 -52
- package/src/App.readonly.test.tsx +3 -18
- package/src/App.rendering.test.tsx +5 -13
- package/src/App.submit-confirm.test.tsx +7 -25
- package/src/App.test.tsx +13 -52
- package/src/App.tsx +197 -910
- package/src/components/AnnotationCard.stories.tsx +75 -0
- package/src/components/AnnotationCard.tsx +136 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +38 -0
- package/src/components/CodeBlock.stories.tsx +25 -0
- package/src/components/CodeBlock.tsx +68 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +50 -0
- package/src/components/ComposeBar.stories.tsx +18 -0
- package/src/components/ComposeBar.tsx +54 -0
- package/src/components/ConfirmCard.stories.tsx +22 -0
- package/src/components/ConfirmCard.tsx +130 -0
- package/src/components/DiffSheet.stories.tsx +44 -0
- package/src/components/DiffSheet.tsx +243 -0
- package/src/components/InboxList.stories.tsx +18 -0
- package/src/components/InboxList.tsx +42 -0
- package/src/components/PlanSheet.stories.tsx +81 -0
- package/src/components/PlanSheet.tsx +309 -0
- package/src/components/ReviewRail.stories.tsx +103 -0
- package/src/components/ReviewRail.tsx +158 -0
- package/src/components/SettingsDialog.stories.tsx +63 -0
- package/src/components/SettingsDialog.tsx +132 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +92 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1189 -0
- package/src/components/format.ts +6 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +44 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +58 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +52 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +22 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +97 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/stories-app.tsx +94 -0
- package/src/components/stories.test.tsx +51 -0
- package/src/components/story-fixtures.ts +90 -0
- package/src/components/story.ts +66 -0
- package/src/components/syntax-highlight.ts +57 -0
- package/src/components/theme-context.stories.tsx +41 -0
- package/src/components/theme-context.tsx +30 -0
- package/src/key-bindings.test.ts +103 -0
- package/src/key-bindings.ts +332 -0
- package/src/keymap.ts +8 -2
- package/src/run.ts +3 -1
- package/src/session-controller.ts +28 -7
- package/src/test-support.ts +87 -0
- package/src/theme.ts +3 -0
- package/src/view.test.ts +35 -13
- package/src/view.ts +41 -64
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
|
@@ -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";
|
|
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
|
+
dispIdx: 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(dispIdx: number): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface PlanComposeState {
|
|
50
|
+
kind: "comment" | "suggestion";
|
|
51
|
+
dispIdx: 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: { dispIdx: number; start: number; end: number } | null;
|
|
63
|
+
compose: PlanComposeState | null;
|
|
64
|
+
editOrphanCount: number;
|
|
65
|
+
onLineActivate: (dispIdx: 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 [dispIdx, 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 { dispIdx, ...range };
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
},
|
|
121
|
+
driveSpanSelection: (span: SpanState): void => {
|
|
122
|
+
if (!renderer) return;
|
|
123
|
+
const blockRef = blockRefs.current.get(span.dispIdx);
|
|
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: (dispIdx: number): void => {
|
|
137
|
+
try {
|
|
138
|
+
scrollRef.current?.scrollChildIntoView(`plan-block-${dispIdx}`);
|
|
139
|
+
} catch {
|
|
140
|
+
// reveal is best-effort; selection state is already correct
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
}));
|
|
144
|
+
|
|
145
|
+
const registerBlock = (dispIdx: number, renderable: TextRenderable | null, runs: StyleRun[]): void => {
|
|
146
|
+
if (renderable) blockRefs.current.set(dispIdx, { renderable, runs });
|
|
147
|
+
else blockRefs.current.delete(dispIdx);
|
|
148
|
+
};
|
|
149
|
+
// stale refs must not survive a shrinking display list
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
for (const dispIdx of blockRefs.current.keys()) {
|
|
152
|
+
if (dispIdx >= display.length) blockRefs.current.delete(dispIdx);
|
|
153
|
+
}
|
|
154
|
+
}, [display]);
|
|
155
|
+
|
|
156
|
+
const children: React.ReactNode[] = [];
|
|
157
|
+
for (let dispIdx = 0; dispIdx < display.length; dispIdx++) {
|
|
158
|
+
const block = display[dispIdx]!;
|
|
159
|
+
const isCursor = dispIdx === cursor;
|
|
160
|
+
const gap = topGap(display[dispIdx - 1], block);
|
|
161
|
+
if (block.kind === "code") {
|
|
162
|
+
children.push(
|
|
163
|
+
<CodeBlock
|
|
164
|
+
key={dispIdx}
|
|
165
|
+
id={`plan-block-${dispIdx}`}
|
|
166
|
+
language={(block.work ?? block.base)?.lang}
|
|
167
|
+
content={displayText(block)}
|
|
168
|
+
isCursor={isCursor}
|
|
169
|
+
marginTop={gap}
|
|
170
|
+
isAnnotated={(marks.get(dispIdx) ?? []).length > 0}
|
|
171
|
+
changeTag={block.type !== "same" ? tagLabel(block) : undefined}
|
|
172
|
+
theme={theme}
|
|
173
|
+
/>,
|
|
174
|
+
);
|
|
175
|
+
} else {
|
|
176
|
+
const blockMarks = [...(marks.get(dispIdx) ?? [])];
|
|
177
|
+
if (activeSpan && activeSpan.dispIdx === dispIdx) {
|
|
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={dispIdx} id={`plan-block-${dispIdx}`} 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(dispIdx, renderable, mappedRuns)}
|
|
197
|
+
onMouseUp={() => onLineActivate(dispIdx)}
|
|
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.dispIdx === dispIdx) {
|
|
210
|
+
children.push(
|
|
211
|
+
<AnnotationCard key={`compose-${dispIdx}`} 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.oliNum ?? 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,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
|
+
};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The right review rail: tab strip (Review/Agent), the scrollable annotation
|
|
3
|
+
* stack, and the pinned submit affordance. The confirm card sits OUTSIDE the
|
|
4
|
+
* scrollbox so the stack scrolls while the card stays at the rail bottom.
|
|
5
|
+
* Card reveal goes through the scrollbox's own scrollChildIntoView - no
|
|
6
|
+
* hardcoded card-height arithmetic.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React, { forwardRef, useImperativeHandle, useRef } from "react";
|
|
10
|
+
import type { ScrollBoxRenderable } from "@opentui/core";
|
|
11
|
+
import type { Annotation, ReviewSession } from "@cueloop/schema";
|
|
12
|
+
import type { Theme } from "../theme";
|
|
13
|
+
import { useComponentTheme } from "./theme-context";
|
|
14
|
+
import { Tabs, Tab, TabList } from "./primitives/Tabs";
|
|
15
|
+
import { Button } from "./primitives/Button";
|
|
16
|
+
import { AnnotationCard, type AnnotationDraft } from "./AnnotationCard";
|
|
17
|
+
import { ConfirmCard, type ConfirmCardProps } from "./ConfirmCard";
|
|
18
|
+
|
|
19
|
+
export type RailTab = "review" | "agent";
|
|
20
|
+
|
|
21
|
+
export interface RailCardEdit extends AnnotationDraft {
|
|
22
|
+
id: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ReviewRailHandle {
|
|
26
|
+
/** Scroll the card for an annotation into view (selection symmetry). */
|
|
27
|
+
revealCard(annotationId: string): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ReviewRailProps {
|
|
31
|
+
session: ReviewSession;
|
|
32
|
+
selectedId?: string;
|
|
33
|
+
/** Ids whose anchor resolved; null = orphan display off (diff view). */
|
|
34
|
+
resolvedIds: Set<string> | null;
|
|
35
|
+
railTab: RailTab;
|
|
36
|
+
pendingCount: number;
|
|
37
|
+
cardEdit: RailCardEdit | null;
|
|
38
|
+
submitConfirm: Omit<ConfirmCardProps, "theme"> | null;
|
|
39
|
+
onTabChange: (tab: RailTab) => void;
|
|
40
|
+
onSelectCard: (id: string) => void;
|
|
41
|
+
onActivateCard: (id: string) => void;
|
|
42
|
+
onSubmitRequest: () => void;
|
|
43
|
+
/** Rail column width; the app derives it from the terminal dimensions. */
|
|
44
|
+
width?: number;
|
|
45
|
+
theme?: Theme;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Forward-compatible: open annotation kinds may carry a blocking flag. */
|
|
49
|
+
export function annotationBlocking(annotation: Annotation): boolean {
|
|
50
|
+
return (annotation as Annotation & { blocking?: boolean }).blocking === true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function ReviewRail(
|
|
54
|
+
{
|
|
55
|
+
session,
|
|
56
|
+
selectedId,
|
|
57
|
+
resolvedIds,
|
|
58
|
+
railTab,
|
|
59
|
+
pendingCount,
|
|
60
|
+
cardEdit,
|
|
61
|
+
submitConfirm,
|
|
62
|
+
onTabChange,
|
|
63
|
+
onSelectCard,
|
|
64
|
+
onActivateCard,
|
|
65
|
+
onSubmitRequest,
|
|
66
|
+
width = 34,
|
|
67
|
+
theme,
|
|
68
|
+
}: ReviewRailProps,
|
|
69
|
+
handleRef,
|
|
70
|
+
): React.ReactNode {
|
|
71
|
+
const tokens = useComponentTheme(theme);
|
|
72
|
+
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
73
|
+
|
|
74
|
+
useImperativeHandle(handleRef, () => ({
|
|
75
|
+
revealCard: (annotationId: string): void => {
|
|
76
|
+
try {
|
|
77
|
+
scrollRef.current?.scrollChildIntoView(`annotation-card-${annotationId}`);
|
|
78
|
+
} catch {
|
|
79
|
+
// reveal is best-effort; selection state is already correct
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<box style={{ width, backgroundColor: tokens.panel, flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
|
|
86
|
+
<Tabs selectedKey={railTab} onSelectionChange={(key) => onTabChange(key as RailTab)} theme={theme}>
|
|
87
|
+
<TabList>
|
|
88
|
+
<Tab id="review">{`Review (${pendingCount})`}</Tab>
|
|
89
|
+
<Tab id="agent">Agent</Tab>
|
|
90
|
+
</TabList>
|
|
91
|
+
</Tabs>
|
|
92
|
+
<text> </text>
|
|
93
|
+
{railTab === "agent" ? (
|
|
94
|
+
<AgentTab session={session} theme={theme} />
|
|
95
|
+
) : (
|
|
96
|
+
<>
|
|
97
|
+
{session.workingCopy !== undefined ? <text fg={tokens.textDim}>± plan edits → one diff</text> : null}
|
|
98
|
+
{session.annotations.length === 0 ? (
|
|
99
|
+
<text fg={tokens.textDim}>no annotations yet</text>
|
|
100
|
+
) : (
|
|
101
|
+
<scrollbox ref={scrollRef} style={{ flexGrow: 1 }} focused={false}>
|
|
102
|
+
{session.annotations.map((annotation) => (
|
|
103
|
+
<AnnotationCard
|
|
104
|
+
key={annotation.id}
|
|
105
|
+
id={`annotation-card-${annotation.id}`}
|
|
106
|
+
kind={annotation.kind}
|
|
107
|
+
quote={annotation.anchor.quote}
|
|
108
|
+
theme={theme}
|
|
109
|
+
saved={{
|
|
110
|
+
body: annotation.body,
|
|
111
|
+
isSelected: annotation.id === selectedId,
|
|
112
|
+
isOrphan: resolvedIds !== null && !resolvedIds.has(annotation.id),
|
|
113
|
+
isBlocking: annotationBlocking(annotation),
|
|
114
|
+
editing:
|
|
115
|
+
cardEdit && cardEdit.id === annotation.id
|
|
116
|
+
? {
|
|
117
|
+
text: cardEdit.text,
|
|
118
|
+
onInput: cardEdit.onInput,
|
|
119
|
+
onSave: cardEdit.onSave,
|
|
120
|
+
onCancel: cardEdit.onCancel,
|
|
121
|
+
}
|
|
122
|
+
: null,
|
|
123
|
+
onPress: () =>
|
|
124
|
+
annotation.id === selectedId ? onActivateCard(annotation.id) : onSelectCard(annotation.id),
|
|
125
|
+
}}
|
|
126
|
+
/>
|
|
127
|
+
))}
|
|
128
|
+
</scrollbox>
|
|
129
|
+
)}
|
|
130
|
+
<box style={{ flexGrow: 1 }} />
|
|
131
|
+
{/* the confirm card sits OUTSIDE the scrollbox: the annotation stack
|
|
132
|
+
above scrolls while the card stays pinned to the rail bottom */}
|
|
133
|
+
{session.status === "resolved" ? (
|
|
134
|
+
<text fg={tokens.green}>resolved: {session.verdict!.kind.replace("_", " ")}</text>
|
|
135
|
+
) : submitConfirm ? (
|
|
136
|
+
<ConfirmCard {...submitConfirm} theme={theme} />
|
|
137
|
+
) : (
|
|
138
|
+
<Button variant="accent-text" onPress={onSubmitRequest} theme={theme}>
|
|
139
|
+
{`Submit review (${pendingCount}) ⏎`}
|
|
140
|
+
</Button>
|
|
141
|
+
)}
|
|
142
|
+
</>
|
|
143
|
+
)}
|
|
144
|
+
</box>
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
/** Agent tab: who submitted, where the session stands, which revision. */
|
|
149
|
+
function AgentTab({ session, theme }: { session: ReviewSession; theme?: Theme }): React.ReactNode {
|
|
150
|
+
const tokens = useComponentTheme(theme);
|
|
151
|
+
return (
|
|
152
|
+
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
153
|
+
<text fg={tokens.textMuted}>{session.artifact.meta.agent ?? "unknown"}</text>
|
|
154
|
+
<text fg={tokens.textDim}>status: {session.status}</text>
|
|
155
|
+
<text fg={tokens.textDim}>revision {session.revisions.length}</text>
|
|
156
|
+
</box>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { SettingsDialog, type SettingsCategory } from "./SettingsDialog";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "SettingsDialog" };
|
|
7
|
+
|
|
8
|
+
const CATEGORIES: SettingsCategory[] = [
|
|
9
|
+
{
|
|
10
|
+
id: "general",
|
|
11
|
+
name: "General",
|
|
12
|
+
description: "identity and submission",
|
|
13
|
+
rows: [
|
|
14
|
+
{ key: "displayName", label: "Display name", kind: "text" },
|
|
15
|
+
{ key: "autoClose", label: "Auto-close on submit", kind: "cycle", options: ["off", "3s", "on"] },
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: "display",
|
|
20
|
+
name: "Display",
|
|
21
|
+
description: "plan width and chrome",
|
|
22
|
+
rows: [
|
|
23
|
+
{ key: "planWidth", label: "Plan width", kind: "cycle", options: ["default", "wide", "full"] },
|
|
24
|
+
{ key: "showLineNumbers", label: "Line numbers", kind: "toggle" },
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const VALUES = { displayName: "amber-heron", autoClose: "3s", planWidth: "default", showLineNumbers: true };
|
|
30
|
+
|
|
31
|
+
export const NavZone: Story = {
|
|
32
|
+
render: () => (
|
|
33
|
+
<SettingsDialog
|
|
34
|
+
isOpen
|
|
35
|
+
categories={CATEGORIES}
|
|
36
|
+
values={VALUES}
|
|
37
|
+
activeCategoryId="general"
|
|
38
|
+
activeRowIndex={0}
|
|
39
|
+
activeZone="nav"
|
|
40
|
+
onCategorySelect={() => {}}
|
|
41
|
+
onRowActivate={() => {}}
|
|
42
|
+
/>
|
|
43
|
+
),
|
|
44
|
+
expectedColors: [DARK.accent, DARK.backdrop],
|
|
45
|
+
size: { width: 100, height: 30 },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const BodyZone: Story = {
|
|
49
|
+
render: () => (
|
|
50
|
+
<SettingsDialog
|
|
51
|
+
isOpen
|
|
52
|
+
categories={CATEGORIES}
|
|
53
|
+
values={VALUES}
|
|
54
|
+
activeCategoryId="display"
|
|
55
|
+
activeRowIndex={1}
|
|
56
|
+
activeZone="body"
|
|
57
|
+
onCategorySelect={() => {}}
|
|
58
|
+
onRowActivate={() => {}}
|
|
59
|
+
/>
|
|
60
|
+
),
|
|
61
|
+
expectedColors: [DARK.green],
|
|
62
|
+
size: { width: 100, height: 30 },
|
|
63
|
+
};
|