@cueloop/client 0.1.0-alpha.20 → 0.1.0-alpha.22
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.autoclose.test.tsx +29 -5
- package/src/App.diff.test.tsx +20 -0
- package/src/App.export.test.tsx +12 -8
- package/src/App.plan-review.test.tsx +182 -7
- package/src/App.readonly.test.tsx +19 -5
- package/src/App.rendering.test.tsx +21 -2
- package/src/App.review-panel.test.tsx +120 -0
- package/src/App.submit-confirm.test.tsx +57 -6
- package/src/App.test.tsx +62 -5
- package/src/App.tsx +147 -185
- package/src/App.walk.test.tsx +63 -8
- 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.tsx +66 -13
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +116 -0
- package/src/components/ReviewRail.tsx +30 -5
- package/src/components/__snapshots__/stories.test.tsx.snap +140 -0
- package/src/components/primitives/Card.tsx +2 -1
- package/src/components/primitives/Dialog.tsx +2 -1
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/stories-app.tsx +2 -1
- package/src/components/stories.test.tsx +5 -0
- package/src/config.test.ts +113 -1
- package/src/config.ts +58 -22
- package/src/editor.test.ts +24 -0
- package/src/herdr.test.ts +4 -0
- package/src/index.ts +2 -0
- package/src/integrations.test.ts +12 -0
- package/src/intent-dispatch.test.ts +213 -0
- package/src/intent-dispatch.ts +270 -0
- package/src/key-bindings.test.ts +16 -5
- package/src/keymap.test.ts +141 -9
- package/src/keymap.ts +54 -7
- package/src/review-panel.test.ts +111 -0
- package/src/review-panel.ts +76 -0
- package/src/serve.test.ts +7 -0
- package/src/session-controller.ts +39 -4
- package/src/share.ts +47 -0
- package/src/view-diff.test.ts +11 -0
- package/src/view-diff.ts +6 -6
- package/src/view-plan.test.ts +81 -0
- package/src/view-plan.ts +3 -0
- package/src/walk.test.ts +6 -0
package/src/clipboard.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort system clipboard. Each platform has its own tool; we try them in
|
|
3
|
+
* order and report whether one worked, so the caller can fall back to printing
|
|
4
|
+
* the line (the over-SSH case, where no clipboard tool exists). Mirrors how
|
|
5
|
+
* herdr confirms a copy.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The clipboard-write commands to try, most-preferred first, for this platform. */
|
|
9
|
+
export function clipboardCommands(platform: NodeJS.Platform = process.platform): string[][] {
|
|
10
|
+
if (platform === "darwin") return [["pbcopy"]];
|
|
11
|
+
if (platform === "win32") return [["clip.exe"]];
|
|
12
|
+
return [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Copy `text` to the clipboard; false when no tool is available or all fail. */
|
|
16
|
+
export async function copyToClipboard(text: string): Promise<boolean> {
|
|
17
|
+
for (const command of clipboardCommands()) {
|
|
18
|
+
try {
|
|
19
|
+
const proc = Bun.spawn(command, { stdin: Buffer.from(text), stdout: "ignore", stderr: "ignore" });
|
|
20
|
+
if ((await proc.exited) === 0) return true;
|
|
21
|
+
} catch {
|
|
22
|
+
// tool not installed - try the next one
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composer's auto-grow row math as a pure function: hard newlines and soft
|
|
3
|
+
* wrapping both add rows, growth stops at the cap, and a non-positive width
|
|
4
|
+
* (before layout is known) degrades to a hard-newline count. The live textarea
|
|
5
|
+
* scrolls internally past the cap, so this only needs to get the growth curve
|
|
6
|
+
* and the ceiling right.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { COMPOSE_MAX_ROWS, composeRowCount } from "./AnnotationCard";
|
|
11
|
+
|
|
12
|
+
describe("composeRowCount", () => {
|
|
13
|
+
const width = 10;
|
|
14
|
+
|
|
15
|
+
test("empty and short text are one row", () => {
|
|
16
|
+
expect(composeRowCount("", width)).toBe(1);
|
|
17
|
+
expect(composeRowCount("hi", width)).toBe(1);
|
|
18
|
+
expect(composeRowCount("exactly-10", width)).toBe(1); // length 10 / width 10
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("hard newlines add rows", () => {
|
|
22
|
+
expect(composeRowCount("a\nb", width)).toBe(2);
|
|
23
|
+
expect(composeRowCount("a\nb\nc", width)).toBe(3);
|
|
24
|
+
// a blank trailing line still occupies a row
|
|
25
|
+
expect(composeRowCount("a\n", width)).toBe(2);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("a soft-wrapped long line grows the box", () => {
|
|
29
|
+
expect(composeRowCount("a".repeat(11), width)).toBe(2); // ceil(11/10)
|
|
30
|
+
expect(composeRowCount("a".repeat(25), width)).toBe(3); // ceil(25/10)
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("wrapping and hard newlines combine", () => {
|
|
34
|
+
// 1 row for "short" plus ceil(21/10)=3 rows for the long line
|
|
35
|
+
expect(composeRowCount("short\n" + "b".repeat(21), width)).toBe(4);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("growth is capped", () => {
|
|
39
|
+
expect(composeRowCount("a".repeat(100), width)).toBe(COMPOSE_MAX_ROWS);
|
|
40
|
+
expect(composeRowCount("l\ni\nn\ne\ns\nx", width)).toBe(COMPOSE_MAX_ROWS);
|
|
41
|
+
expect(composeRowCount("a\nb", width, 1)).toBe(1); // custom cap
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a non-positive width counts hard newlines only, never divides by zero", () => {
|
|
45
|
+
expect(composeRowCount("a".repeat(100), 0)).toBe(1);
|
|
46
|
+
expect(composeRowCount("a\nb\nc", 0)).toBe(3);
|
|
47
|
+
expect(composeRowCount("a".repeat(100), -5)).toBe(1);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
* its body in place. Compose, saved, and re-edit share this rendering path,
|
|
5
5
|
* so the kind-color and quote treatment can never drift between them.
|
|
6
6
|
*
|
|
7
|
-
* Bodies are multiline: the composer is a textarea (wrap, undo/redo
|
|
8
|
-
* shift+⏎
|
|
7
|
+
* Bodies are multiline: the composer is a textarea (wrap, undo/redo). Plain ⏎
|
|
8
|
+
* stays the save key while ⌥/Alt+⏎ and shift+⏎ insert a new line (the Slack
|
|
9
|
+
* convention). The box auto-grows with the wrapped text up to a cap, then
|
|
10
|
+
* scrolls internally.
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
|
-
import React, { useEffect, useRef } from "react";
|
|
13
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
12
14
|
import type { KeyBinding, TextareaRenderable } from "@opentui/core";
|
|
13
15
|
import type { Theme } from "../theme";
|
|
14
16
|
import { useComponentTheme } from "./theme-context";
|
|
@@ -43,24 +45,63 @@ export interface AnnotationCardProps {
|
|
|
43
45
|
theme?: Theme;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* ⏎ submits (which keeps the textarea from inserting its default newline; the
|
|
50
|
+
* grammar owns the actual save). ⌥/Alt+⏎ and shift+⏎ insert a newline so the
|
|
51
|
+
* body can grow multiline without leaving the composer.
|
|
52
|
+
*
|
|
53
|
+
* Option/Alt reaches a binding as `meta`, not `option`: a binding key here is
|
|
54
|
+
* built from name+ctrl+shift+meta+super only, so the newline binding matches on
|
|
55
|
+
* `meta`. This also overrides the textarea default that maps meta+⏎ to submit.
|
|
56
|
+
*/
|
|
47
57
|
const COMPOSE_KEY_BINDINGS: KeyBinding[] = [
|
|
48
58
|
{ name: "return", action: "submit" },
|
|
59
|
+
{ name: "return", meta: true, action: "newline" },
|
|
49
60
|
{ name: "return", shift: true, action: "newline" },
|
|
50
61
|
];
|
|
51
62
|
|
|
52
|
-
|
|
53
|
-
|
|
63
|
+
/** The composer never grows past this many rows; beyond it the textarea scrolls. */
|
|
64
|
+
export const COMPOSE_MAX_ROWS = 4;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Visible row count for the composer, so a soft-wrapped long line grows the box
|
|
68
|
+
* just like a hard newline does. Each logical line takes `ceil(length /
|
|
69
|
+
* contentWidth)` visual rows (at least one), summed over the hard-newline lines
|
|
70
|
+
* and capped. A non-positive width (before layout is known) counts hard
|
|
71
|
+
* newlines only. Beyond the cap the textarea scrolls internally and keeps the
|
|
72
|
+
* caret line in view.
|
|
73
|
+
*/
|
|
74
|
+
export function composeRowCount(text: string, contentWidth: number, cap: number = COMPOSE_MAX_ROWS): number {
|
|
75
|
+
const usableWidth = contentWidth > 0 ? contentWidth : Number.MAX_SAFE_INTEGER;
|
|
76
|
+
let visualRowCount = 0;
|
|
77
|
+
for (const line of text.split("\n")) {
|
|
78
|
+
visualRowCount += Math.max(1, Math.ceil(line.length / usableWidth));
|
|
79
|
+
}
|
|
80
|
+
return Math.min(cap, Math.max(1, visualRowCount));
|
|
54
81
|
}
|
|
55
82
|
|
|
56
|
-
function DraftEditor({
|
|
83
|
+
function DraftEditor({
|
|
84
|
+
draft,
|
|
85
|
+
rows,
|
|
86
|
+
onRowsChange,
|
|
87
|
+
theme,
|
|
88
|
+
}: {
|
|
89
|
+
draft: AnnotationDraft;
|
|
90
|
+
/** Current visible height in rows; owned by the parent so the card frame agrees. */
|
|
91
|
+
rows: number;
|
|
92
|
+
onRowsChange: (rows: number) => void;
|
|
93
|
+
theme?: Theme;
|
|
94
|
+
}): React.ReactNode {
|
|
57
95
|
const tokens = useComponentTheme(theme);
|
|
58
96
|
const editorRef = useRef<TextareaRenderable | null>(null);
|
|
59
97
|
// re-edit opens with the caret after the existing body, like a text field
|
|
60
98
|
const initialTextLength = useRef(draft.text.length);
|
|
61
99
|
useEffect(() => {
|
|
62
100
|
const editor = editorRef.current;
|
|
63
|
-
if (editor)
|
|
101
|
+
if (!editor) return;
|
|
102
|
+
editor.cursorOffset = initialTextLength.current;
|
|
103
|
+
// the box is laid out now, so its width is known: size to the wrapped body
|
|
104
|
+
onRowsChange(composeRowCount(editor.plainText, editor.width));
|
|
64
105
|
}, []);
|
|
65
106
|
return (
|
|
66
107
|
<>
|
|
@@ -72,10 +113,12 @@ function DraftEditor({ draft, theme }: { draft: AnnotationDraft; theme?: Theme }
|
|
|
72
113
|
keyBindings={COMPOSE_KEY_BINDINGS}
|
|
73
114
|
onContentChange={() => {
|
|
74
115
|
const editor = editorRef.current;
|
|
75
|
-
if (editor)
|
|
116
|
+
if (!editor) return;
|
|
117
|
+
draft.onInput(editor.plainText);
|
|
118
|
+
onRowsChange(composeRowCount(editor.plainText, editor.width));
|
|
76
119
|
}}
|
|
77
120
|
style={{
|
|
78
|
-
height:
|
|
121
|
+
height: rows,
|
|
79
122
|
backgroundColor: tokens.elevated,
|
|
80
123
|
focusedBackgroundColor: tokens.elevated,
|
|
81
124
|
textColor: tokens.text,
|
|
@@ -97,18 +140,28 @@ function DraftEditor({ draft, theme }: { draft: AnnotationDraft; theme?: Theme }
|
|
|
97
140
|
export function AnnotationCard({ id, kind, quote, draft, saved, theme }: AnnotationCardProps): React.ReactNode {
|
|
98
141
|
const tokens = useComponentTheme(theme);
|
|
99
142
|
const kindColor = kind === "suggestion" ? tokens.green : tokens.accent;
|
|
143
|
+
const activeDraft = draft ?? saved?.editing ?? null;
|
|
144
|
+
// The composer's visible height, grown from the wrapped text. It is shared
|
|
145
|
+
// with the card frame (contentRows) so the border never clips the textarea
|
|
146
|
+
// or the toolbar. Seeded from hard newlines, then corrected once the mounted
|
|
147
|
+
// textarea reports its measured width.
|
|
148
|
+
const [editorRowCount, setEditorRowCount] = useState(() =>
|
|
149
|
+
// Width 0 is the "layout not known yet" contract: count hard newlines only,
|
|
150
|
+
// until the mounted textarea reports its measured width below.
|
|
151
|
+
composeRowCount(activeDraft?.text ?? "", 0),
|
|
152
|
+
);
|
|
100
153
|
if (draft) {
|
|
101
154
|
const verb = kind === "suggestion" ? "suggest replacement for" : "comment on";
|
|
102
155
|
return (
|
|
103
156
|
<Card
|
|
104
157
|
title={` ${verb} "${truncateToSingleLine(quote, 40)}" `}
|
|
105
|
-
contentRows={
|
|
158
|
+
contentRows={editorRowCount + 1}
|
|
106
159
|
borderColor={kindColor}
|
|
107
160
|
marginLeft={2}
|
|
108
161
|
marginRight={2}
|
|
109
162
|
theme={theme}
|
|
110
163
|
>
|
|
111
|
-
<DraftEditor draft={draft} theme={theme} />
|
|
164
|
+
<DraftEditor draft={draft} rows={editorRowCount} onRowsChange={setEditorRowCount} theme={theme} />
|
|
112
165
|
</Card>
|
|
113
166
|
);
|
|
114
167
|
}
|
|
@@ -127,7 +180,7 @@ export function AnnotationCard({ id, kind, quote, draft, saved, theme }: Annotat
|
|
|
127
180
|
</text>
|
|
128
181
|
<text fg={tokens.textDim}> "{truncateToSingleLine(quote, 26)}"</text>
|
|
129
182
|
{card.editing ? (
|
|
130
|
-
<DraftEditor draft={card.editing} theme={theme} />
|
|
183
|
+
<DraftEditor draft={card.editing} rows={editorRowCount} onRowsChange={setEditorRowCount} theme={theme} />
|
|
131
184
|
) : (
|
|
132
185
|
<text fg={card.isOrphan ? tokens.textDim : tokens.textMuted}> {truncateToSingleLine(card.body, 28)}</text>
|
|
133
186
|
)}
|
|
@@ -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
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import React, { forwardRef, useImperativeHandle, useRef } from "react";
|
|
10
10
|
import type { ScrollBoxRenderable } from "@opentui/core";
|
|
11
|
-
import type
|
|
11
|
+
import { isAddressed, type Annotation, type ReviewSession } from "@cueloop/schema";
|
|
12
12
|
import type { Theme } from "../theme";
|
|
13
13
|
import { useComponentTheme } from "./theme-context";
|
|
14
14
|
import { Tabs, Tab, TabList } from "./primitives/Tabs";
|
|
@@ -40,8 +40,15 @@ export interface ReviewRailProps {
|
|
|
40
40
|
onSelectCard: (id: string) => void;
|
|
41
41
|
onActivateCard: (id: string) => void;
|
|
42
42
|
onSubmitRequest: () => void;
|
|
43
|
-
/**
|
|
43
|
+
/** Publish the plan as a share; omitted for non-owners (hides the button). */
|
|
44
|
+
onShareRequest?: () => void;
|
|
45
|
+
/** Rail column width; the app derives it from the persisted review layout. */
|
|
44
46
|
width?: number;
|
|
47
|
+
/**
|
|
48
|
+
* When set, renders the muted `»` chevron pinned at the rail's bottom-left,
|
|
49
|
+
* one column from the divider. Clicking it collapses the panel to compact.
|
|
50
|
+
*/
|
|
51
|
+
onCollapse?: () => void;
|
|
45
52
|
theme?: Theme;
|
|
46
53
|
}
|
|
47
54
|
|
|
@@ -63,13 +70,18 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
|
|
|
63
70
|
onSelectCard,
|
|
64
71
|
onActivateCard,
|
|
65
72
|
onSubmitRequest,
|
|
73
|
+
onShareRequest,
|
|
66
74
|
width = 34,
|
|
75
|
+
onCollapse,
|
|
67
76
|
theme,
|
|
68
77
|
}: ReviewRailProps,
|
|
69
78
|
handleRef,
|
|
70
79
|
): React.ReactNode {
|
|
71
80
|
const tokens = useComponentTheme(theme);
|
|
72
81
|
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
82
|
+
// addressed annotations leave the card list; a dim count keeps them honest
|
|
83
|
+
const openAnnotations = session.annotations.filter((annotation) => !isAddressed(annotation));
|
|
84
|
+
const addressedCount = session.annotations.length - openAnnotations.length;
|
|
73
85
|
|
|
74
86
|
useImperativeHandle(handleRef, () => ({
|
|
75
87
|
revealCard: (annotationId: string): void => {
|
|
@@ -95,11 +107,12 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
|
|
|
95
107
|
) : (
|
|
96
108
|
<>
|
|
97
109
|
{session.workingCopy !== undefined ? <text fg={tokens.textDim}>± plan edits → one diff</text> : null}
|
|
98
|
-
{
|
|
99
|
-
|
|
110
|
+
{addressedCount > 0 ? <text fg={tokens.textDim}>✓ {addressedCount} addressed by revision</text> : null}
|
|
111
|
+
{openAnnotations.length === 0 ? (
|
|
112
|
+
<text fg={tokens.textDim}>{session.annotations.length === 0 ? "no annotations yet" : "all annotations addressed"}</text>
|
|
100
113
|
) : (
|
|
101
114
|
<scrollbox ref={scrollRef} style={{ flexGrow: 1 }} focused={false}>
|
|
102
|
-
{
|
|
115
|
+
{openAnnotations.map((annotation) => (
|
|
103
116
|
<AnnotationCard
|
|
104
117
|
key={annotation.id}
|
|
105
118
|
id={`annotation-card-${annotation.id}`}
|
|
@@ -139,8 +152,20 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
|
|
|
139
152
|
{`Submit review (${pendingCount}) ⏎`}
|
|
140
153
|
</Button>
|
|
141
154
|
)}
|
|
155
|
+
{onShareRequest && session.status !== "resolved" ? (
|
|
156
|
+
<Button variant="plain" onPress={onShareRequest} theme={theme}>
|
|
157
|
+
{"Share plan ⇧S"}
|
|
158
|
+
</Button>
|
|
159
|
+
) : null}
|
|
142
160
|
</>
|
|
143
161
|
)}
|
|
162
|
+
{/* the collapse chevron: muted, left-bound, one column from the divider.
|
|
163
|
+
» points right - it hands the width back toward the plan */}
|
|
164
|
+
{onCollapse ? (
|
|
165
|
+
<box style={{ flexDirection: "row" }} onMouseUp={onCollapse}>
|
|
166
|
+
<text fg={tokens.textDim}>»</text>
|
|
167
|
+
</box>
|
|
168
|
+
) : null}
|
|
144
169
|
</box>
|
|
145
170
|
);
|
|
146
171
|
});
|
|
@@ -1315,5 +1315,145 @@ exports[`stories catalog WalkWizard/WithAgentNote renders, snapshots, and carrie
|
|
|
1315
1315
|
|
|
1316
1316
|
|
|
1317
1317
|
|
|
1318
|
+
"
|
|
1319
|
+
`;
|
|
1320
|
+
|
|
1321
|
+
exports[`stories catalog ReviewPanel/Compact renders, snapshots, and carries its colors: ReviewPanel/Compact 1`] = `
|
|
1322
|
+
" The plan takes the width the panel hands back. │
|
|
1323
|
+
│ 2
|
|
1324
|
+
│
|
|
1325
|
+
│ ●
|
|
1326
|
+
│ ●
|
|
1327
|
+
│
|
|
1328
|
+
│
|
|
1329
|
+
│
|
|
1330
|
+
│
|
|
1331
|
+
│
|
|
1332
|
+
│
|
|
1333
|
+
│
|
|
1334
|
+
│
|
|
1335
|
+
│
|
|
1336
|
+
│
|
|
1337
|
+
│
|
|
1338
|
+
│
|
|
1339
|
+
│
|
|
1340
|
+
│
|
|
1341
|
+
│
|
|
1342
|
+
│
|
|
1343
|
+
│
|
|
1344
|
+
│
|
|
1345
|
+
│ «
|
|
1346
|
+
"
|
|
1347
|
+
`;
|
|
1348
|
+
|
|
1349
|
+
exports[`stories catalog ReviewPanel/DraggingDivider renders, snapshots, and carries its colors: ReviewPanel/DraggingDivider 1`] = `
|
|
1350
|
+
" The plan takes the width the panel hands │
|
|
1351
|
+
back. │ Review (2) Agent
|
|
1352
|
+
│
|
|
1353
|
+
│ ▸ COMMENT · pending
|
|
1354
|
+
│ "persists sessions"
|
|
1355
|
+
│ Which daemon owns this?
|
|
1356
|
+
│
|
|
1357
|
+
│ SUGGESTION · pending
|
|
1358
|
+
│ "move the store"
|
|
1359
|
+
│ move the store behind one i…
|
|
1360
|
+
│
|
|
1361
|
+
│
|
|
1362
|
+
│
|
|
1363
|
+
│
|
|
1364
|
+
│
|
|
1365
|
+
│
|
|
1366
|
+
│
|
|
1367
|
+
│
|
|
1368
|
+
│
|
|
1369
|
+
│
|
|
1370
|
+
│
|
|
1371
|
+
│
|
|
1372
|
+
│ Submit review (2) ⏎
|
|
1373
|
+
│ »
|
|
1374
|
+
"
|
|
1375
|
+
`;
|
|
1376
|
+
|
|
1377
|
+
exports[`stories catalog ReviewPanel/ExpandedNarrow renders, snapshots, and carries its colors: ReviewPanel/ExpandedNarrow 1`] = `
|
|
1378
|
+
" The plan takes the width the panel hands back. │
|
|
1379
|
+
│ Review (2) Agent
|
|
1380
|
+
│
|
|
1381
|
+
│ ▸ COMMENT · pending
|
|
1382
|
+
│ "persists sessions"
|
|
1383
|
+
│ Which daemon owns
|
|
1384
|
+
│ this?
|
|
1385
|
+
│
|
|
1386
|
+
│ SUGGESTION · pending
|
|
1387
|
+
│ "move the store"
|
|
1388
|
+
│ move the store
|
|
1389
|
+
│ behind one i…
|
|
1390
|
+
│
|
|
1391
|
+
│
|
|
1392
|
+
│
|
|
1393
|
+
│
|
|
1394
|
+
│
|
|
1395
|
+
│
|
|
1396
|
+
│
|
|
1397
|
+
│
|
|
1398
|
+
│
|
|
1399
|
+
│
|
|
1400
|
+
│ Submit review (2) ⏎
|
|
1401
|
+
│ »
|
|
1402
|
+
"
|
|
1403
|
+
`;
|
|
1404
|
+
|
|
1405
|
+
exports[`stories catalog ReviewPanel/ExpandedWide renders, snapshots, and carries its colors: ReviewPanel/ExpandedWide 1`] = `
|
|
1406
|
+
" The plan takes the width the panel │
|
|
1407
|
+
hands back. │ Review (2) Agent
|
|
1408
|
+
│
|
|
1409
|
+
│ ▸ COMMENT · pending
|
|
1410
|
+
│ "persists sessions"
|
|
1411
|
+
│ Which daemon owns this?
|
|
1412
|
+
│
|
|
1413
|
+
│ SUGGESTION · pending
|
|
1414
|
+
│ "move the store"
|
|
1415
|
+
│ move the store behind one i…
|
|
1416
|
+
│
|
|
1417
|
+
│
|
|
1418
|
+
│
|
|
1419
|
+
│
|
|
1420
|
+
│
|
|
1421
|
+
│
|
|
1422
|
+
│
|
|
1423
|
+
│
|
|
1424
|
+
│
|
|
1425
|
+
│
|
|
1426
|
+
│
|
|
1427
|
+
│
|
|
1428
|
+
│ Submit review (2) ⏎
|
|
1429
|
+
│ »
|
|
1430
|
+
"
|
|
1431
|
+
`;
|
|
1432
|
+
|
|
1433
|
+
exports[`stories catalog ReviewPanel/Hidden renders, snapshots, and carries its colors: ReviewPanel/Hidden 1`] = `
|
|
1434
|
+
" The plan takes the width the panel hands back.
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
|
|
1318
1458
|
"
|
|
1319
1459
|
`;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import React from "react";
|
|
9
9
|
import type { Theme } from "../../theme";
|
|
10
10
|
import { useComponentTheme } from "../theme-context";
|
|
11
|
+
import { FRAME_BORDER_STYLE } from "./frame";
|
|
11
12
|
|
|
12
13
|
export interface CardProps {
|
|
13
14
|
title?: string;
|
|
@@ -44,7 +45,7 @@ export function Card({
|
|
|
44
45
|
marginLeft,
|
|
45
46
|
marginRight,
|
|
46
47
|
border: true,
|
|
47
|
-
borderStyle:
|
|
48
|
+
borderStyle: FRAME_BORDER_STYLE,
|
|
48
49
|
borderColor: borderColor ?? tokens.border,
|
|
49
50
|
backgroundColor: backgroundColor ?? tokens.elevated,
|
|
50
51
|
flexDirection: "column",
|