@cueloop/client 0.1.0-alpha.3 → 0.1.0-alpha.30
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 +17 -11
- package/src/App.autoclose.test.tsx +133 -0
- package/src/App.diff.test.tsx +27 -20
- package/src/App.export.test.tsx +19 -28
- package/src/App.plan-review.test.tsx +507 -0
- package/src/App.readonly.test.tsx +26 -27
- package/src/App.rendering.test.tsx +119 -0
- package/src/App.review-panel.test.tsx +123 -0
- package/src/App.submit-confirm.test.tsx +216 -0
- package/src/App.test.tsx +83 -64
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +265 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +32 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +26 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +75 -0
- package/src/components/AnnotationCard.tsx +208 -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 +49 -0
- package/src/components/ComposeBar.stories.tsx +18 -0
- package/src/components/ComposeBar.tsx +54 -0
- package/src/components/ConfirmCard.stories.tsx +37 -0
- package/src/components/ConfirmCard.tsx +117 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +56 -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 +55 -0
- package/src/components/PlanSheet.stories.tsx +81 -0
- package/src/components/PlanSheet.tsx +329 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +62 -0
- package/src/components/ReviewPanel.stories.tsx +87 -0
- package/src/components/ReviewPanel.tsx +116 -0
- package/src/components/ReviewRail.stories.tsx +107 -0
- package/src/components/ReviewRail.tsx +180 -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/WalkWizard.stories.tsx +89 -0
- package/src/components/WalkWizard.tsx +146 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1519 -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 +59 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +53 -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/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/stories-app.tsx +95 -0
- package/src/components/stories.test.tsx +56 -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/components/truncate-text.ts +6 -0
- package/src/config.test.ts +196 -30
- package/src/config.ts +128 -16
- package/src/editor.test.ts +134 -0
- package/src/editor.ts +101 -13
- package/src/herdr.test.ts +41 -0
- package/src/herdr.ts +21 -0
- package/src/index.ts +2 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +28 -0
- package/src/intent-dispatch.test.ts +308 -0
- package/src/intent-dispatch.ts +295 -0
- package/src/key-bindings.test.ts +142 -0
- package/src/key-bindings.ts +363 -0
- package/src/keymap.test.ts +477 -0
- package/src/keymap.ts +272 -0
- package/src/review-panel.test.ts +111 -0
- package/src/review-panel.ts +76 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +13 -6
- package/src/serve.ts +7 -7
- package/src/session-controller.share.test.ts +248 -0
- package/src/session-controller.ts +607 -0
- package/src/share.ts +80 -0
- package/src/test-support.ts +87 -0
- package/src/theme.ts +23 -1
- package/src/view-diff.test.ts +19 -8
- package/src/view-diff.ts +22 -22
- package/src/view-plan.test.ts +260 -0
- package/src/view-plan.ts +365 -0
- package/src/walk.test.ts +116 -0
- package/src/walk.ts +65 -0
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
|
@@ -0,0 +1,329 @@
|
|
|
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
|
+
onShareRequest: () => void;
|
|
68
|
+
/** Owner-only: show the Edit and Share affordances. A share viewer never sees either. */
|
|
69
|
+
canEdit: boolean;
|
|
70
|
+
theme?: Theme;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface BlockRef {
|
|
74
|
+
renderable: TextRenderable;
|
|
75
|
+
runs: StyleRun[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Screen position of a rendered-text offset inside a wrapped text. */
|
|
79
|
+
function positionOfRenderedOffset(renderable: TextRenderable, renderedOffset: number): { x: number; y: number } {
|
|
80
|
+
const info = renderable.lineInfo;
|
|
81
|
+
let lineIndex = 0;
|
|
82
|
+
for (let index = 0; index < info.lineStartCols.length; index++) {
|
|
83
|
+
if (info.lineStartCols[index]! <= renderedOffset) lineIndex = index;
|
|
84
|
+
else break;
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
x: renderable.x + renderedOffset - (info.lineStartCols[lineIndex] ?? 0),
|
|
88
|
+
y: renderable.y + lineIndex,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function PlanSheet(
|
|
93
|
+
{
|
|
94
|
+
session,
|
|
95
|
+
display,
|
|
96
|
+
marks,
|
|
97
|
+
cursor,
|
|
98
|
+
activeSpan,
|
|
99
|
+
compose,
|
|
100
|
+
editOrphanCount,
|
|
101
|
+
onLineActivate,
|
|
102
|
+
onEditRequest,
|
|
103
|
+
onShareRequest,
|
|
104
|
+
canEdit,
|
|
105
|
+
theme,
|
|
106
|
+
}: PlanSheetProps,
|
|
107
|
+
handleRef,
|
|
108
|
+
): React.ReactNode {
|
|
109
|
+
const tokens = useComponentTheme(theme);
|
|
110
|
+
const renderer = useRenderer();
|
|
111
|
+
const blockRefs = useRef(new Map<number, BlockRef>());
|
|
112
|
+
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
113
|
+
|
|
114
|
+
useImperativeHandle(handleRef, () => ({
|
|
115
|
+
readSelection: (): PlanSelection | null => {
|
|
116
|
+
if (!renderer?.hasSelection) return null;
|
|
117
|
+
const ordered = [...blockRefs.current.entries()].sort(([a], [b]) => a - b);
|
|
118
|
+
for (const [displayIndex, blockRef] of ordered) {
|
|
119
|
+
const selection = blockRef.renderable.getSelection();
|
|
120
|
+
if (!selection || selection.end <= selection.start) continue;
|
|
121
|
+
const range = workRangeForRendered(blockRef.runs, selection.start, selection.end);
|
|
122
|
+
if (range) return { displayIndex, ...range };
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
},
|
|
126
|
+
driveSpanSelection: (span: SpanState): void => {
|
|
127
|
+
if (!renderer) return;
|
|
128
|
+
const blockRef = blockRefs.current.get(span.displayIndex);
|
|
129
|
+
if (!blockRef) return;
|
|
130
|
+
const renderedStart = renderedOffsetFor(blockRef.runs, span.start);
|
|
131
|
+
const renderedEnd = renderedOffsetFor(blockRef.runs, span.end - 1);
|
|
132
|
+
if (renderedStart === null || renderedEnd === null) return;
|
|
133
|
+
const startPosition = positionOfRenderedOffset(blockRef.renderable, renderedStart);
|
|
134
|
+
const endPosition = positionOfRenderedOffset(blockRef.renderable, renderedEnd);
|
|
135
|
+
renderer.startSelection(blockRef.renderable, startPosition.x, startPosition.y);
|
|
136
|
+
renderer.updateSelection(blockRef.renderable, endPosition.x + 1, endPosition.y);
|
|
137
|
+
},
|
|
138
|
+
clearSelection: (): void => {
|
|
139
|
+
renderer?.clearSelection();
|
|
140
|
+
},
|
|
141
|
+
revealBlock: (displayIndex: number): void => {
|
|
142
|
+
try {
|
|
143
|
+
scrollRef.current?.scrollChildIntoView(`plan-block-${displayIndex}`);
|
|
144
|
+
} catch {
|
|
145
|
+
// reveal is best-effort; selection state is already correct
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
}));
|
|
149
|
+
|
|
150
|
+
const registerBlock = (displayIndex: number, renderable: TextRenderable | null, runs: StyleRun[]): void => {
|
|
151
|
+
if (renderable) blockRefs.current.set(displayIndex, { renderable, runs });
|
|
152
|
+
else blockRefs.current.delete(displayIndex);
|
|
153
|
+
};
|
|
154
|
+
// stale refs must not survive a shrinking display list
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
for (const displayIndex of blockRefs.current.keys()) {
|
|
157
|
+
if (displayIndex >= display.length) blockRefs.current.delete(displayIndex);
|
|
158
|
+
}
|
|
159
|
+
}, [display]);
|
|
160
|
+
|
|
161
|
+
const children: React.ReactNode[] = [];
|
|
162
|
+
for (let displayIndex = 0; displayIndex < display.length; displayIndex++) {
|
|
163
|
+
const block = display[displayIndex]!;
|
|
164
|
+
const isCursor = displayIndex === cursor;
|
|
165
|
+
const gap = topGap(display[displayIndex - 1], block);
|
|
166
|
+
if (block.kind === "code") {
|
|
167
|
+
children.push(
|
|
168
|
+
<CodeBlock
|
|
169
|
+
key={displayIndex}
|
|
170
|
+
id={`plan-block-${displayIndex}`}
|
|
171
|
+
language={(block.work ?? block.base)?.lang}
|
|
172
|
+
content={displayText(block)}
|
|
173
|
+
isCursor={isCursor}
|
|
174
|
+
marginTop={gap}
|
|
175
|
+
isAnnotated={(marks.get(displayIndex) ?? []).length > 0}
|
|
176
|
+
changeTag={block.type !== "same" ? tagLabel(block) : undefined}
|
|
177
|
+
theme={theme}
|
|
178
|
+
/>,
|
|
179
|
+
);
|
|
180
|
+
} else {
|
|
181
|
+
const blockMarks = [...(marks.get(displayIndex) ?? [])];
|
|
182
|
+
if (activeSpan && activeSpan.displayIndex === displayIndex) {
|
|
183
|
+
blockMarks.push({ start: activeSpan.start, end: activeSpan.end, role: "kspan" });
|
|
184
|
+
}
|
|
185
|
+
const runs = overlayMarks(blockRuns(block, true), blockMarks);
|
|
186
|
+
// the change tag participates in the rendered text but carries no offsets
|
|
187
|
+
const mappedRuns: StyleRun[] =
|
|
188
|
+
block.type !== "same" ? [...runs, { text: ` [${tagLabel(block)}]`, role: "plain", start: null }] : runs;
|
|
189
|
+
children.push(
|
|
190
|
+
<box key={displayIndex} id={`plan-block-${displayIndex}`} style={{ flexDirection: "row", marginTop: gap }}>
|
|
191
|
+
<text selectable={false}>
|
|
192
|
+
<span fg={isCursor ? tokens.accent : tokens.textDim}>{isCursor ? "▎ " : " "}</span>
|
|
193
|
+
<span fg={tokens.textDim}>{marker(block)}</span>
|
|
194
|
+
</text>
|
|
195
|
+
<text
|
|
196
|
+
bg={isCursor ? tokens.cursorBg : undefined}
|
|
197
|
+
selectable
|
|
198
|
+
selectionBg={tokens.accent}
|
|
199
|
+
selectionFg={tokens.accentInk}
|
|
200
|
+
style={{ wrapMode: "word", flexGrow: 1, flexShrink: 1 }}
|
|
201
|
+
ref={(renderable: TextRenderable | null) => registerBlock(displayIndex, renderable, mappedRuns)}
|
|
202
|
+
onMouseUp={() => onLineActivate(displayIndex)}
|
|
203
|
+
>
|
|
204
|
+
{runs.map((run, runIndex) => (
|
|
205
|
+
<span key={runIndex} {...runStyle(run, block, tokens)}>
|
|
206
|
+
{run.text}
|
|
207
|
+
</span>
|
|
208
|
+
))}
|
|
209
|
+
{block.type !== "same" ? <span fg={tagColor(block, tokens)}> [{tagLabel(block)}]</span> : null}
|
|
210
|
+
</text>
|
|
211
|
+
</box>,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
if (compose && compose.displayIndex === displayIndex) {
|
|
215
|
+
children.push(
|
|
216
|
+
<AnnotationCard key={`compose-${displayIndex}`} kind={compose.kind} quote={compose.quote} draft={compose.draft} theme={theme} />,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
223
|
+
<SheetHeader
|
|
224
|
+
session={session}
|
|
225
|
+
onEditRequest={onEditRequest}
|
|
226
|
+
onShareRequest={onShareRequest}
|
|
227
|
+
canEdit={canEdit}
|
|
228
|
+
theme={theme}
|
|
229
|
+
/>
|
|
230
|
+
{editOrphanCount > 0 ? (
|
|
231
|
+
<box style={{ height: 1, backgroundColor: tokens.markCommentBg, paddingLeft: 2 }}>
|
|
232
|
+
<text fg={tokens.red}>
|
|
233
|
+
{editOrphanCount} annotation{editOrphanCount === 1 ? "" : "s"} no longer match - the passage was removed.
|
|
234
|
+
</text>
|
|
235
|
+
</box>
|
|
236
|
+
) : null}
|
|
237
|
+
<scrollbox ref={scrollRef} style={{ flexGrow: 1, paddingLeft: 2, paddingTop: 1 }} focused={false}>
|
|
238
|
+
{children}
|
|
239
|
+
</scrollbox>
|
|
240
|
+
</box>
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
/** Sheet chrome: submitted-by + revision delta left, the Edit and Share word-buttons right. */
|
|
245
|
+
function SheetHeader({
|
|
246
|
+
session,
|
|
247
|
+
onEditRequest,
|
|
248
|
+
onShareRequest,
|
|
249
|
+
canEdit,
|
|
250
|
+
theme,
|
|
251
|
+
}: {
|
|
252
|
+
session: ReviewSession;
|
|
253
|
+
onEditRequest: () => void;
|
|
254
|
+
onShareRequest: () => void;
|
|
255
|
+
canEdit: boolean;
|
|
256
|
+
theme?: Theme;
|
|
257
|
+
}): React.ReactNode {
|
|
258
|
+
const tokens = useComponentTheme(theme);
|
|
259
|
+
const revisionCount = session.revisions.length;
|
|
260
|
+
const previous = revisionCount > 1 ? session.revisions[revisionCount - 2] : undefined;
|
|
261
|
+
const delta = previous ? revisionDelta(previous.content, session.artifact.content) : null;
|
|
262
|
+
return (
|
|
263
|
+
<box style={{ height: 1, flexDirection: "row", paddingLeft: 2, paddingRight: 1 }}>
|
|
264
|
+
<text fg={tokens.textDim}>
|
|
265
|
+
submitted by <span fg={tokens.textMuted}>{session.artifact.meta.agent ?? "unknown"}</span> · revision {revisionCount}
|
|
266
|
+
{delta ? (
|
|
267
|
+
<span fg={tokens.green}>
|
|
268
|
+
{" "}· v{revisionCount - 1}→v{revisionCount} +{delta.added} -{delta.removed}
|
|
269
|
+
</span>
|
|
270
|
+
) : null}
|
|
271
|
+
</text>
|
|
272
|
+
<box style={{ flexGrow: 1 }} />
|
|
273
|
+
{canEdit && session.status !== "resolved" ? (
|
|
274
|
+
<Toolbar>
|
|
275
|
+
<Button onPress={onEditRequest} theme={theme}>
|
|
276
|
+
{" Edit "}
|
|
277
|
+
</Button>
|
|
278
|
+
<Button onPress={onShareRequest} theme={theme}>
|
|
279
|
+
{" Share "}
|
|
280
|
+
</Button>
|
|
281
|
+
</Toolbar>
|
|
282
|
+
) : null}
|
|
283
|
+
</box>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Vertical rhythm: gaps live ABOVE blocks so boundaries never collapse. */
|
|
288
|
+
function topGap(previous: DisplayBlock | undefined, current: DisplayBlock): number {
|
|
289
|
+
if (!previous) return 0;
|
|
290
|
+
const tightPair =
|
|
291
|
+
(current.kind === "li" && previous.kind === "li") || (current.kind === "oli" && previous.kind === "oli");
|
|
292
|
+
return tightPair ? 0 : 1;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function marker(block: DisplayBlock): string {
|
|
296
|
+
if (block.kind === "li") return "- ";
|
|
297
|
+
if (block.kind === "oli") return `${block.orderedItemNumber ?? 1}. `;
|
|
298
|
+
if (block.kind === "quote") return "▏ ";
|
|
299
|
+
return "";
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function tagLabel(block: DisplayBlock): "cut" | "new" | "edited" {
|
|
303
|
+
return block.type === "del" ? "cut" : block.type === "add" ? "new" : "edited";
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function tagColor(block: DisplayBlock, tokens: Theme): string {
|
|
307
|
+
return block.type === "del" ? tokens.red : block.type === "add" ? tokens.green : tokens.accent;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function runStyle(run: StyleRun, block: DisplayBlock, tokens: Theme): { fg?: string; bg?: string } {
|
|
311
|
+
const headingFg = block.kind === "h1" ? tokens.text : block.kind === "h2" || block.kind === "h3" ? tokens.accent : undefined;
|
|
312
|
+
const struck = block.type === "del";
|
|
313
|
+
switch (run.role) {
|
|
314
|
+
case "ins":
|
|
315
|
+
return { fg: tokens.insFg };
|
|
316
|
+
case "del":
|
|
317
|
+
return { fg: tokens.delFg };
|
|
318
|
+
case "mark-comment":
|
|
319
|
+
return { fg: tokens.text, bg: tokens.markCommentBg };
|
|
320
|
+
case "mark-suggestion":
|
|
321
|
+
return { fg: tokens.text, bg: tokens.markSuggestionBg };
|
|
322
|
+
case "mark-focus":
|
|
323
|
+
return { fg: tokens.accentInk, bg: tokens.accent };
|
|
324
|
+
case "kspan":
|
|
325
|
+
return { fg: tokens.accentInk, bg: tokens.accent };
|
|
326
|
+
default:
|
|
327
|
+
return { fg: struck ? tokens.red : (headingFg ?? tokens.text) };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { PromptDialog } from "./PromptDialog";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "PromptDialog" };
|
|
7
|
+
|
|
8
|
+
export const RenameAuthor: Story = {
|
|
9
|
+
render: () => (
|
|
10
|
+
<PromptDialog
|
|
11
|
+
isOpen
|
|
12
|
+
title=" Rename author "
|
|
13
|
+
label="Display name for this collaborator:"
|
|
14
|
+
value="Robin"
|
|
15
|
+
placeholder="their name"
|
|
16
|
+
onInput={() => {}}
|
|
17
|
+
/>
|
|
18
|
+
),
|
|
19
|
+
expectedColors: [DARK.backdrop, DARK.panel, DARK.accent],
|
|
20
|
+
size: { width: 64, height: 16 },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const NameYourself: Story = {
|
|
24
|
+
render: () => (
|
|
25
|
+
<PromptDialog
|
|
26
|
+
isOpen
|
|
27
|
+
title=" Welcome "
|
|
28
|
+
label="Your name (optional) - it attributes the notes you leave:"
|
|
29
|
+
value=""
|
|
30
|
+
placeholder="your name"
|
|
31
|
+
onInput={() => {}}
|
|
32
|
+
/>
|
|
33
|
+
),
|
|
34
|
+
expectedColors: [DARK.backdrop, DARK.panel],
|
|
35
|
+
size: { width: 64, height: 16 },
|
|
36
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A centered single-field text prompt on the shared Dialog overlay. The focused
|
|
3
|
+
* input owns typing/cursor natively; the reducer's `prompt` overlay drives ⏎
|
|
4
|
+
* save / esc cancel (like the composer). Used to rename an author and to ask a
|
|
5
|
+
* collaborator for their name.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useEffect, useRef } from "react";
|
|
9
|
+
import { useTerminalDimensions } from "@opentui/react";
|
|
10
|
+
import type { KeyBinding, TextareaRenderable } from "@opentui/core";
|
|
11
|
+
import type { Theme } from "../theme";
|
|
12
|
+
import { useComponentTheme } from "./theme-context";
|
|
13
|
+
import { Dialog } from "./primitives/Dialog";
|
|
14
|
+
|
|
15
|
+
// ⏎ submits (suppresses the textarea's default newline); the grammar owns save.
|
|
16
|
+
const PROMPT_KEY_BINDINGS: KeyBinding[] = [{ name: "return", action: "submit" }];
|
|
17
|
+
|
|
18
|
+
export interface PromptDialogProps {
|
|
19
|
+
isOpen: boolean;
|
|
20
|
+
title?: string;
|
|
21
|
+
label: string;
|
|
22
|
+
value: string;
|
|
23
|
+
placeholder?: string;
|
|
24
|
+
onInput: (text: string) => void;
|
|
25
|
+
theme?: Theme;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function PromptDialog({ isOpen, title, label, value, placeholder, onInput, theme }: PromptDialogProps): React.ReactNode {
|
|
29
|
+
const tokens = useComponentTheme(theme);
|
|
30
|
+
const { width: terminalWidth } = useTerminalDimensions();
|
|
31
|
+
const inputRef = useRef<TextareaRenderable | null>(null);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
// open with the caret after the seeded value, like a text field
|
|
34
|
+
if (inputRef.current) inputRef.current.cursorOffset = value.length;
|
|
35
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
36
|
+
}, []);
|
|
37
|
+
if (!isOpen) return null;
|
|
38
|
+
return (
|
|
39
|
+
<Dialog isOpen title={title} width={Math.min(54, terminalWidth - 6)} height={7} theme={theme}>
|
|
40
|
+
<box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 1, paddingRight: 1, paddingTop: 1 }}>
|
|
41
|
+
<text fg={tokens.textDim}>{label}</text>
|
|
42
|
+
<textarea
|
|
43
|
+
ref={inputRef}
|
|
44
|
+
focused
|
|
45
|
+
initialValue={value}
|
|
46
|
+
placeholder={placeholder}
|
|
47
|
+
keyBindings={PROMPT_KEY_BINDINGS}
|
|
48
|
+
onContentChange={() => onInput(inputRef.current?.plainText ?? "")}
|
|
49
|
+
style={{
|
|
50
|
+
height: 1,
|
|
51
|
+
backgroundColor: tokens.elevated,
|
|
52
|
+
focusedBackgroundColor: tokens.elevated,
|
|
53
|
+
textColor: tokens.text,
|
|
54
|
+
focusedTextColor: tokens.text,
|
|
55
|
+
}}
|
|
56
|
+
/>
|
|
57
|
+
<box style={{ flexGrow: 1 }} />
|
|
58
|
+
<text fg={tokens.textDim}>⏎ save · esc cancel</text>
|
|
59
|
+
</box>
|
|
60
|
+
</Dialog>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
authorNames: {},
|
|
13
|
+
selectedId: FIXTURE_ANNOTATIONS[0]!.id,
|
|
14
|
+
resolvedIds: new Set(FIXTURE_ANNOTATIONS.map((annotation) => annotation.id)),
|
|
15
|
+
railTab: "review" as const,
|
|
16
|
+
pendingCount: 2,
|
|
17
|
+
cardEdit: null,
|
|
18
|
+
submitConfirm: null,
|
|
19
|
+
onTabChange: () => {},
|
|
20
|
+
onSelectCard: () => {},
|
|
21
|
+
onActivateCard: () => {},
|
|
22
|
+
onSubmitRequest: () => {},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const panelCallbacks = { onDividerGrab: () => {}, onToggle: () => {} };
|
|
26
|
+
|
|
27
|
+
/** The plan area to the left of the divider, so the layout reads in context. */
|
|
28
|
+
function PanelFrame({ children }: { children: React.ReactNode }): React.ReactNode {
|
|
29
|
+
return (
|
|
30
|
+
<box style={{ flexDirection: "row", width: "100%", height: "100%", backgroundColor: DARK.bg }}>
|
|
31
|
+
<box style={{ flexGrow: 1, paddingLeft: 1 }}>
|
|
32
|
+
<text fg={DARK.textMuted}>The plan takes the width the panel hands back.</text>
|
|
33
|
+
</box>
|
|
34
|
+
{children}
|
|
35
|
+
</box>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const ExpandedNarrow: Story = {
|
|
40
|
+
render: () => (
|
|
41
|
+
<PanelFrame>
|
|
42
|
+
<ReviewPanel mode="expanded" width={REVIEW_MIN_WIDTH} height={24} dragging={false} rail={rail} {...panelCallbacks} />
|
|
43
|
+
</PanelFrame>
|
|
44
|
+
),
|
|
45
|
+
expectedColors: [DARK.panel, DARK.border],
|
|
46
|
+
size: { width: 80, height: 24 },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const ExpandedWide: Story = {
|
|
50
|
+
render: () => (
|
|
51
|
+
<PanelFrame>
|
|
52
|
+
<ReviewPanel mode="expanded" width={REVIEW_MAX_WIDTH} height={24} dragging={false} rail={rail} {...panelCallbacks} />
|
|
53
|
+
</PanelFrame>
|
|
54
|
+
),
|
|
55
|
+
expectedColors: [DARK.panel, DARK.green],
|
|
56
|
+
size: { width: 90, height: 24 },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Dragging accents the divider glyph column. */
|
|
60
|
+
export const DraggingDivider: Story = {
|
|
61
|
+
render: () => (
|
|
62
|
+
<PanelFrame>
|
|
63
|
+
<ReviewPanel mode="expanded" width={34} height={24} dragging={true} rail={rail} {...panelCallbacks} />
|
|
64
|
+
</PanelFrame>
|
|
65
|
+
),
|
|
66
|
+
expectedColors: [DARK.accent],
|
|
67
|
+
size: { width: 80, height: 24 },
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const Compact: Story = {
|
|
71
|
+
render: () => (
|
|
72
|
+
<PanelFrame>
|
|
73
|
+
<ReviewPanel mode="compact" width={34} height={24} dragging={false} rail={rail} {...panelCallbacks} />
|
|
74
|
+
</PanelFrame>
|
|
75
|
+
),
|
|
76
|
+
expectedColors: [DARK.accent, DARK.green],
|
|
77
|
+
size: { width: 60, height: 24 },
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const Hidden: Story = {
|
|
81
|
+
render: () => (
|
|
82
|
+
<PanelFrame>
|
|
83
|
+
<ReviewPanel mode="hidden" width={34} height={24} dragging={false} rail={rail} {...panelCallbacks} />
|
|
84
|
+
</PanelFrame>
|
|
85
|
+
),
|
|
86
|
+
size: { width: 60, height: 24 },
|
|
87
|
+
};
|
|
@@ -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
|
+
}
|