@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.
- package/package.json +17 -11
- package/src/App.autoclose.test.tsx +109 -0
- package/src/App.diff.test.tsx +5 -18
- package/src/App.export.test.tsx +8 -13
- package/src/App.plan-review.test.tsx +293 -0
- package/src/App.readonly.test.tsx +6 -21
- package/src/App.rendering.test.tsx +104 -0
- package/src/App.review-panel.test.tsx +97 -0
- package/src/App.submit-confirm.test.tsx +165 -0
- package/src/App.test.tsx +20 -58
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +208 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +75 -0
- package/src/components/AnnotationCard.tsx +189 -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/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/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +116 -0
- package/src/components/ReviewRail.stories.tsx +103 -0
- package/src/components/ReviewRail.tsx +171 -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 +1459 -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 +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/components/truncate-text.ts +6 -0
- package/src/config.test.ts +105 -30
- package/src/config.ts +91 -16
- package/src/editor.test.ts +110 -0
- package/src/editor.ts +101 -13
- package/src/herdr.test.ts +37 -0
- package/src/herdr.ts +21 -0
- package/src/integrations.test.ts +29 -0
- package/src/integrations.ts +28 -0
- package/src/key-bindings.test.ts +120 -0
- package/src/key-bindings.ts +353 -0
- package/src/keymap.test.ts +347 -0
- package/src/keymap.ts +229 -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 +6 -6
- package/src/serve.ts +7 -7
- package/src/session-controller.ts +512 -0
- package/src/test-support.ts +87 -0
- package/src/theme.ts +23 -1
- package/src/view-diff.test.ts +8 -8
- package/src/view-diff.ts +22 -22
- package/src/view-plan.test.ts +179 -0
- package/src/view-plan.ts +362 -0
- package/src/walk.test.ts +110 -0
- package/src/walk.ts +65 -0
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { CompletionOverlay } from "./CompletionOverlay";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "CompletionOverlay" };
|
|
7
|
+
|
|
8
|
+
export const ApprovedPrompt: Story = {
|
|
9
|
+
render: () => <CompletionOverlay verdict="approve" completion={{ phase: "prompt" }} status="" />,
|
|
10
|
+
expectedColors: [DARK.green],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const FeedbackCounting: Story = {
|
|
14
|
+
render: () => (
|
|
15
|
+
<CompletionOverlay
|
|
16
|
+
verdict="request_changes"
|
|
17
|
+
completion={{ phase: "counting", remaining: 3 }}
|
|
18
|
+
status="exported to vault/cueloop/plan.md"
|
|
19
|
+
returnsTo="agent/worker-3"
|
|
20
|
+
/>
|
|
21
|
+
),
|
|
22
|
+
expectedColors: [DARK.accent],
|
|
23
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-submit hand-back overlay: the agent is unblocked, the reviewer either
|
|
3
|
+
* closes now, opts into auto-close, or dismisses back to the resolved view.
|
|
4
|
+
* The latest status line (e.g. the vault-export path) stays visible here.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React from "react";
|
|
8
|
+
import type { VerdictKind } from "@cueloop/schema";
|
|
9
|
+
import type { Theme } from "../theme";
|
|
10
|
+
import { useComponentTheme } from "./theme-context";
|
|
11
|
+
|
|
12
|
+
export interface CompletionOverlayProps {
|
|
13
|
+
verdict: VerdictKind;
|
|
14
|
+
completion: { phase: "prompt" } | { phase: "counting"; remaining: number };
|
|
15
|
+
/** Latest status line (e.g. the vault-export path) stays visible here. */
|
|
16
|
+
status: string;
|
|
17
|
+
/** Where focus goes on close (the agent's pane), when known. */
|
|
18
|
+
returnsTo?: string;
|
|
19
|
+
theme?: Theme;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function CompletionOverlay({ verdict, completion, status, returnsTo, theme }: CompletionOverlayProps): React.ReactNode {
|
|
23
|
+
const tokens = useComponentTheme(theme);
|
|
24
|
+
const approved = verdict === "approve";
|
|
25
|
+
return (
|
|
26
|
+
<box
|
|
27
|
+
style={{
|
|
28
|
+
width: "100%",
|
|
29
|
+
height: "100%",
|
|
30
|
+
backgroundColor: tokens.bg,
|
|
31
|
+
flexDirection: "column",
|
|
32
|
+
justifyContent: "center",
|
|
33
|
+
alignItems: "center",
|
|
34
|
+
}}
|
|
35
|
+
>
|
|
36
|
+
<text fg={approved ? tokens.green : tokens.accent}>{approved ? "review approved" : "feedback sent"}</text>
|
|
37
|
+
<text> </text>
|
|
38
|
+
<text fg={tokens.text}>The agent has your {approved ? "approval" : "feedback"} and is unblocked.</text>
|
|
39
|
+
{returnsTo ? <text fg={tokens.textDim}>returning to {returnsTo} on close</text> : null}
|
|
40
|
+
{status ? <text fg={tokens.textDim}>{status}</text> : null}
|
|
41
|
+
<text> </text>
|
|
42
|
+
<text fg={tokens.textDim}>
|
|
43
|
+
close [return]
|
|
44
|
+
{completion.phase === "counting" ? ` · closing in ${completion.remaining}s` : ""}
|
|
45
|
+
{" · return to plan [esc] · always [a]"}
|
|
46
|
+
</text>
|
|
47
|
+
</box>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { ComposeBar } from "./ComposeBar";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "ComposeBar" };
|
|
7
|
+
|
|
8
|
+
export const CommentOnLine: Story = {
|
|
9
|
+
render: () => (
|
|
10
|
+
<ComposeBar kind="comment" quote=" private items = new Map();" text="Map needs an eviction story." onInput={() => {}} />
|
|
11
|
+
),
|
|
12
|
+
expectedColors: [DARK.accent, DARK.elevated],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const SuggestionOnLine: Story = {
|
|
16
|
+
render: () => <ComposeBar kind="suggestion" quote="export class Store {" text="" onInput={() => {}} />,
|
|
17
|
+
expectedColors: [DARK.green],
|
|
18
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bottom compose bar - the diff view composes annotation bodies here. The
|
|
3
|
+
* editor is the same multiline textarea contract as the inline composer:
|
|
4
|
+
* ⏎ saves through the grammar, shift+⏎ adds a line.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React, { useRef } from "react";
|
|
8
|
+
import type { KeyBinding, TextareaRenderable } from "@opentui/core";
|
|
9
|
+
import type { Theme } from "../theme";
|
|
10
|
+
import { useComponentTheme } from "./theme-context";
|
|
11
|
+
import { truncateToSingleLine } from "./truncate-text";
|
|
12
|
+
|
|
13
|
+
export interface ComposeBarProps {
|
|
14
|
+
kind: "comment" | "suggestion";
|
|
15
|
+
quote: string;
|
|
16
|
+
text: string;
|
|
17
|
+
onInput: (text: string) => void;
|
|
18
|
+
theme?: Theme;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const COMPOSE_KEY_BINDINGS: KeyBinding[] = [
|
|
22
|
+
{ name: "return", action: "submit" },
|
|
23
|
+
{ name: "return", shift: true, action: "newline" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export function ComposeBar({ kind, quote, text, onInput, theme }: ComposeBarProps): React.ReactNode {
|
|
27
|
+
const tokens = useComponentTheme(theme);
|
|
28
|
+
const editorRef = useRef<TextareaRenderable | null>(null);
|
|
29
|
+
const editorRows = Math.min(4, Math.max(1, text.split("\n").length));
|
|
30
|
+
return (
|
|
31
|
+
<box style={{ height: 1 + editorRows, backgroundColor: tokens.elevated, flexDirection: "column", paddingLeft: 1 }}>
|
|
32
|
+
<text fg={kind === "suggestion" ? tokens.green : tokens.accent}>
|
|
33
|
+
{kind === "suggestion" ? "SUGGEST REPLACEMENT FOR" : "COMMENT ON"} “{truncateToSingleLine(quote, 60)}” · ⏎ save · esc cancel
|
|
34
|
+
</text>
|
|
35
|
+
<textarea
|
|
36
|
+
ref={editorRef}
|
|
37
|
+
focused
|
|
38
|
+
initialValue=""
|
|
39
|
+
keyBindings={COMPOSE_KEY_BINDINGS}
|
|
40
|
+
onContentChange={() => {
|
|
41
|
+
const editor = editorRef.current;
|
|
42
|
+
if (editor) onInput(editor.plainText);
|
|
43
|
+
}}
|
|
44
|
+
style={{
|
|
45
|
+
height: editorRows,
|
|
46
|
+
backgroundColor: tokens.elevated,
|
|
47
|
+
focusedBackgroundColor: tokens.elevated,
|
|
48
|
+
textColor: tokens.text,
|
|
49
|
+
focusedTextColor: tokens.text,
|
|
50
|
+
}}
|
|
51
|
+
/>
|
|
52
|
+
</box>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { ConfirmCard } from "./ConfirmCard";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "ConfirmCard" };
|
|
7
|
+
|
|
8
|
+
const callbacks = { onInput: () => {}, onSelectVerdict: () => {}, onSubmit: () => {}, onCancel: () => {} };
|
|
9
|
+
|
|
10
|
+
export const ApproveDefault: Story = {
|
|
11
|
+
render: () => <ConfirmCard verdict="approve" summary="" annotationCount={0} blockingCount={0} {...callbacks} />,
|
|
12
|
+
expectedColors: [DARK.green, DARK.accent],
|
|
13
|
+
size: { width: 40, height: 14 },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const RequestChanges: Story = {
|
|
17
|
+
render: () => (
|
|
18
|
+
<ConfirmCard verdict="request_changes" summary="Tighten the steps." annotationCount={3} blockingCount={1} {...callbacks} />
|
|
19
|
+
),
|
|
20
|
+
expectedColors: [DARK.red],
|
|
21
|
+
size: { width: 40, height: 14 },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const WithViewedSummary: Story = {
|
|
25
|
+
render: () => (
|
|
26
|
+
<ConfirmCard
|
|
27
|
+
verdict="approve"
|
|
28
|
+
summary=""
|
|
29
|
+
annotationCount={1}
|
|
30
|
+
blockingCount={0}
|
|
31
|
+
viewedSummary="2/3 files viewed"
|
|
32
|
+
{...callbacks}
|
|
33
|
+
/>
|
|
34
|
+
),
|
|
35
|
+
expectedColors: [DARK.green, DARK.textDim],
|
|
36
|
+
size: { width: 40, height: 14 },
|
|
37
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Submit button expanded into a bordered confirm card at the rail bottom:
|
|
3
|
+
* honest counts, the verdict selector (arrow keys or click), the optional
|
|
4
|
+
* summary, and plain word-buttons - key hints live in the status line only.
|
|
5
|
+
* The bordered height derives from the content rows through Card, so layout
|
|
6
|
+
* and render never drift.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React from "react";
|
|
10
|
+
import type { VerdictKind } from "@cueloop/schema";
|
|
11
|
+
import type { Theme } from "../theme";
|
|
12
|
+
import { useComponentTheme } from "./theme-context";
|
|
13
|
+
import { Card } from "./primitives/Card";
|
|
14
|
+
import { Button } from "./primitives/Button";
|
|
15
|
+
import { Toolbar } from "./primitives/Toolbar";
|
|
16
|
+
|
|
17
|
+
export const VERDICTS: VerdictKind[] = ["comment", "approve", "request_changes"];
|
|
18
|
+
|
|
19
|
+
/** Selector words in the confirm card - one word per verdict. */
|
|
20
|
+
export const VERDICT_LABEL: Record<VerdictKind, string> = {
|
|
21
|
+
comment: "Comment",
|
|
22
|
+
approve: "Approve",
|
|
23
|
+
request_changes: "Changes",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface ConfirmCardProps {
|
|
27
|
+
verdict: VerdictKind;
|
|
28
|
+
summary: string;
|
|
29
|
+
annotationCount: number;
|
|
30
|
+
blockingCount: number;
|
|
31
|
+
/**
|
|
32
|
+
* The guided walk's honest coverage line for diff sessions, e.g.
|
|
33
|
+
* "2/3 files viewed". Undefined = no walk data, the row does not render.
|
|
34
|
+
*/
|
|
35
|
+
viewedSummary?: string;
|
|
36
|
+
onInput: (summary: string) => void;
|
|
37
|
+
onSelectVerdict: (verdict: VerdictKind) => void;
|
|
38
|
+
onSubmit: () => void;
|
|
39
|
+
onCancel: () => void;
|
|
40
|
+
theme?: Theme;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Counts, spacer, 1-row verdict selector, spacer, summary input, spacer, buttons. */
|
|
44
|
+
const CONFIRM_CONTENT_ROWS = 7;
|
|
45
|
+
|
|
46
|
+
export function verdictColor(verdict: VerdictKind, tokens: Theme): string {
|
|
47
|
+
return verdict === "approve" ? tokens.green : verdict === "request_changes" ? tokens.red : tokens.blue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The verdict selector: one row of pressable words, matching the reading
|
|
52
|
+
* direction of a choice between three peers. Selection stays controlled by
|
|
53
|
+
* the grammar (←/→ cycle the verdict); a click selects directly. The
|
|
54
|
+
* selected verdict wears brackets and its color.
|
|
55
|
+
*/
|
|
56
|
+
function VerdictSelector({
|
|
57
|
+
verdict,
|
|
58
|
+
onSelectVerdict,
|
|
59
|
+
theme,
|
|
60
|
+
}: {
|
|
61
|
+
verdict: VerdictKind;
|
|
62
|
+
onSelectVerdict: (verdict: VerdictKind) => void;
|
|
63
|
+
theme?: Theme;
|
|
64
|
+
}): React.ReactNode {
|
|
65
|
+
const tokens = useComponentTheme(theme);
|
|
66
|
+
return (
|
|
67
|
+
<box style={{ flexDirection: "row", height: 1 }}>
|
|
68
|
+
{VERDICTS.map((candidate) => (
|
|
69
|
+
<box key={candidate} style={{ paddingRight: 1 }} onMouseUp={() => onSelectVerdict(candidate)}>
|
|
70
|
+
<text fg={candidate === verdict ? verdictColor(candidate, tokens) : tokens.textDim}>
|
|
71
|
+
{candidate === verdict ? `[${VERDICT_LABEL[candidate]}]` : ` ${VERDICT_LABEL[candidate]} `}
|
|
72
|
+
</text>
|
|
73
|
+
</box>
|
|
74
|
+
))}
|
|
75
|
+
</box>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function ConfirmCard({
|
|
80
|
+
verdict,
|
|
81
|
+
summary,
|
|
82
|
+
annotationCount,
|
|
83
|
+
blockingCount,
|
|
84
|
+
viewedSummary,
|
|
85
|
+
onInput,
|
|
86
|
+
onSelectVerdict,
|
|
87
|
+
onSubmit,
|
|
88
|
+
onCancel,
|
|
89
|
+
theme,
|
|
90
|
+
}: ConfirmCardProps): React.ReactNode {
|
|
91
|
+
const tokens = useComponentTheme(theme);
|
|
92
|
+
return (
|
|
93
|
+
<Card
|
|
94
|
+
title=" submit review "
|
|
95
|
+
contentRows={CONFIRM_CONTENT_ROWS + (viewedSummary !== undefined ? 1 : 0)}
|
|
96
|
+
borderColor={tokens.accent}
|
|
97
|
+
marginRight={1}
|
|
98
|
+
theme={theme}
|
|
99
|
+
>
|
|
100
|
+
<text fg={tokens.textDim}>{`${annotationCount} annotations · ${blockingCount} blocking`}</text>
|
|
101
|
+
{viewedSummary !== undefined ? <text fg={tokens.textDim}>{viewedSummary}</text> : null}
|
|
102
|
+
<box style={{ height: 1 }} />
|
|
103
|
+
<VerdictSelector verdict={verdict} onSelectVerdict={onSelectVerdict} theme={theme} />
|
|
104
|
+
<box style={{ height: 1 }} />
|
|
105
|
+
<input focused value={summary} onInput={onInput} placeholder="summary for the agent (optional)" />
|
|
106
|
+
<box style={{ height: 1 }} />
|
|
107
|
+
<Toolbar>
|
|
108
|
+
<Button variant="solid" marginRight={2} onPress={onSubmit} theme={theme}>
|
|
109
|
+
{" Submit "}
|
|
110
|
+
</Button>
|
|
111
|
+
<Button onPress={onCancel} theme={theme}>
|
|
112
|
+
{" Cancel "}
|
|
113
|
+
</Button>
|
|
114
|
+
</Toolbar>
|
|
115
|
+
</Card>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { DiffSheet } from "./DiffSheet";
|
|
5
|
+
import { FIXTURE_PATCH, fixtureDiffRows } from "./story-fixtures";
|
|
6
|
+
|
|
7
|
+
export const meta: StoryMeta = { title: "DiffSheet" };
|
|
8
|
+
|
|
9
|
+
export const SignedRows: Story = {
|
|
10
|
+
render: () => <DiffSheet rows={fixtureDiffRows()} cursor={0} annotations={[]} />,
|
|
11
|
+
expectedColors: [DARK.insFg, DARK.delFg],
|
|
12
|
+
size: { width: 90, height: 16 },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const CursorOnAddedLine: Story = {
|
|
16
|
+
render: () => <DiffSheet rows={fixtureDiffRows()} cursor={4} annotations={[]} />,
|
|
17
|
+
expectedColors: [DARK.cursorBg],
|
|
18
|
+
size: { width: 90, height: 16 },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const AnnotatedLine: Story = {
|
|
22
|
+
render: () => (
|
|
23
|
+
<DiffSheet
|
|
24
|
+
rows={fixtureDiffRows()}
|
|
25
|
+
cursor={0}
|
|
26
|
+
annotations={[
|
|
27
|
+
{
|
|
28
|
+
id: "a_story_diff",
|
|
29
|
+
kind: "comment",
|
|
30
|
+
anchor: {
|
|
31
|
+
// diff anchors quote the row text verbatim, trailing newline included
|
|
32
|
+
quote: FIXTURE_PATCH.split("\n").find((line) => line.startsWith("+ "))!.slice(1) + "\n",
|
|
33
|
+
prefix: "",
|
|
34
|
+
suffix: "",
|
|
35
|
+
},
|
|
36
|
+
body: "Map needs an eviction story.",
|
|
37
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
38
|
+
},
|
|
39
|
+
]}
|
|
40
|
+
/>
|
|
41
|
+
),
|
|
42
|
+
expectedColors: [DARK.markCommentBg],
|
|
43
|
+
size: { width: 90, height: 16 },
|
|
44
|
+
};
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The diff review sheet: every row of the patch renders (no window cap)
|
|
3
|
+
* inside a scrollbox, with file headers, hunk headers, and contiguous line
|
|
4
|
+
* chunks. Line chunks draw through the native line-number gutter - custom
|
|
5
|
+
* numbers per row (old numbers on deletions), the cursor as a gutter sign,
|
|
6
|
+
* and annotation markers as line signs. Chunks split after an annotated row
|
|
7
|
+
* so the annotation body renders directly under its line.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React, { useEffect, useMemo, useRef } from "react";
|
|
11
|
+
import type { LineNumberRenderable, LineSign, ScrollBoxRenderable } from "@opentui/core";
|
|
12
|
+
import type { Annotation } from "@cueloop/schema";
|
|
13
|
+
import type { DiffRow } from "../view-diff";
|
|
14
|
+
import type { Theme } from "../theme";
|
|
15
|
+
import { useComponentTheme } from "./theme-context";
|
|
16
|
+
import { truncateToSingleLine } from "./truncate-text";
|
|
17
|
+
|
|
18
|
+
export interface DiffSheetProps {
|
|
19
|
+
rows: DiffRow[];
|
|
20
|
+
cursor: number;
|
|
21
|
+
annotations: Annotation[];
|
|
22
|
+
focusedAnnotationId?: string;
|
|
23
|
+
theme?: Theme;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type DiffSegment =
|
|
27
|
+
| { kind: "header"; rowIndex: number; row: DiffRow }
|
|
28
|
+
| { kind: "chunk"; firstRowIndex: number; rows: DiffRow[]; annotation: Annotation | null };
|
|
29
|
+
|
|
30
|
+
/** Row text carries the patch's trailing newline; rendering strips it. */
|
|
31
|
+
function rowLine(row: DiffRow): string {
|
|
32
|
+
return row.text.replace(/\n$/, "");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Chunks split after an annotated row so its card can sit directly below. */
|
|
36
|
+
function segmentRows(rows: DiffRow[], annotatedByRow: Map<number, Annotation>): DiffSegment[] {
|
|
37
|
+
const segments: DiffSegment[] = [];
|
|
38
|
+
let chunk: DiffRow[] = [];
|
|
39
|
+
let chunkStart = 0;
|
|
40
|
+
const closeChunk = (annotation: Annotation | null): void => {
|
|
41
|
+
if (chunk.length) segments.push({ kind: "chunk", firstRowIndex: chunkStart, rows: chunk, annotation });
|
|
42
|
+
chunk = [];
|
|
43
|
+
};
|
|
44
|
+
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
|
|
45
|
+
const row = rows[rowIndex]!;
|
|
46
|
+
if (row.kind === "file" || row.kind === "hunk") {
|
|
47
|
+
closeChunk(null);
|
|
48
|
+
segments.push({ kind: "header", rowIndex, row });
|
|
49
|
+
chunkStart = rowIndex + 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!chunk.length) chunkStart = rowIndex;
|
|
53
|
+
chunk.push(row);
|
|
54
|
+
const annotation = annotatedByRow.get(rowIndex);
|
|
55
|
+
if (annotation) {
|
|
56
|
+
closeChunk(annotation);
|
|
57
|
+
chunkStart = rowIndex + 1;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
closeChunk(null);
|
|
61
|
+
return segments;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function DiffChunk({
|
|
65
|
+
segment,
|
|
66
|
+
cursor,
|
|
67
|
+
focusedAnnotationId,
|
|
68
|
+
theme,
|
|
69
|
+
}: {
|
|
70
|
+
segment: Extract<DiffSegment, { kind: "chunk" }>;
|
|
71
|
+
cursor: number;
|
|
72
|
+
focusedAnnotationId?: string;
|
|
73
|
+
theme?: Theme;
|
|
74
|
+
}): React.ReactNode {
|
|
75
|
+
const tokens = useComponentTheme(theme);
|
|
76
|
+
const gutterRef = useRef<LineNumberRenderable | null>(null);
|
|
77
|
+
const cursorInChunk = cursor - segment.firstRowIndex;
|
|
78
|
+
|
|
79
|
+
const lineNumbers = useMemo(() => {
|
|
80
|
+
const numbers = new Map<number, number>();
|
|
81
|
+
segment.rows.forEach((row, lineIndex) => {
|
|
82
|
+
const gutterNumber = row.kind === "del" ? row.oldLine : row.newLine;
|
|
83
|
+
if (gutterNumber !== undefined) numbers.set(lineIndex, gutterNumber);
|
|
84
|
+
});
|
|
85
|
+
return numbers;
|
|
86
|
+
}, [segment]);
|
|
87
|
+
|
|
88
|
+
const lineSigns = useMemo(() => {
|
|
89
|
+
const signs = new Map<number, LineSign>();
|
|
90
|
+
if (cursorInChunk >= 0 && cursorInChunk < segment.rows.length) {
|
|
91
|
+
signs.set(cursorInChunk, { before: "▎", beforeColor: tokens.accent });
|
|
92
|
+
}
|
|
93
|
+
if (segment.annotation) {
|
|
94
|
+
signs.set(segment.rows.length - 1, { after: "◆", afterColor: tokens.accent });
|
|
95
|
+
}
|
|
96
|
+
return signs;
|
|
97
|
+
}, [segment, cursorInChunk, tokens]);
|
|
98
|
+
|
|
99
|
+
const lineColors = useMemo(() => {
|
|
100
|
+
// the renderable's config parser requires the gutter key to be present
|
|
101
|
+
const colors = new Map<number, { gutter: string | undefined; content: string }>();
|
|
102
|
+
if (segment.annotation) {
|
|
103
|
+
colors.set(segment.rows.length - 1, { gutter: undefined, content: tokens.markCommentBg });
|
|
104
|
+
}
|
|
105
|
+
if (cursorInChunk >= 0 && cursorInChunk < segment.rows.length) {
|
|
106
|
+
colors.set(cursorInChunk, { gutter: tokens.cursorBg, content: tokens.cursorBg });
|
|
107
|
+
}
|
|
108
|
+
return colors;
|
|
109
|
+
}, [segment, cursorInChunk, tokens]);
|
|
110
|
+
|
|
111
|
+
// gutter state flows through the imperative surface: the renderable keeps
|
|
112
|
+
// no prop setters for these maps, so updates land via effects
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
const gutter = gutterRef.current;
|
|
115
|
+
if (!gutter) return;
|
|
116
|
+
gutter.setLineNumbers(lineNumbers);
|
|
117
|
+
gutter.setLineSigns(lineSigns);
|
|
118
|
+
gutter.clearAllLineColors();
|
|
119
|
+
for (const [line, colorConfig] of lineColors) gutter.setLineColor(line, colorConfig);
|
|
120
|
+
}, [lineNumbers, lineSigns, lineColors]);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<box style={{ flexDirection: "column" }}>
|
|
124
|
+
<line-number
|
|
125
|
+
ref={gutterRef}
|
|
126
|
+
fg={tokens.textDim}
|
|
127
|
+
bg={tokens.bg}
|
|
128
|
+
minWidth={4}
|
|
129
|
+
paddingRight={1}
|
|
130
|
+
lineNumbers={lineNumbers}
|
|
131
|
+
lineSigns={lineSigns}
|
|
132
|
+
>
|
|
133
|
+
<text style={{ wrapMode: "none" }} selectable={false}>
|
|
134
|
+
{segment.rows.map((row, lineIndex) => {
|
|
135
|
+
const sign = row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ";
|
|
136
|
+
const foreground = row.kind === "add" ? tokens.insFg : row.kind === "del" ? tokens.delFg : tokens.textMuted;
|
|
137
|
+
const isCursorRow = lineIndex === cursorInChunk;
|
|
138
|
+
const isAnnotatedRow = segment.annotation !== null && lineIndex === segment.rows.length - 1;
|
|
139
|
+
return (
|
|
140
|
+
<span
|
|
141
|
+
key={lineIndex}
|
|
142
|
+
fg={foreground}
|
|
143
|
+
bg={isCursorRow ? tokens.cursorBg : isAnnotatedRow ? tokens.markCommentBg : undefined}
|
|
144
|
+
>
|
|
145
|
+
{(lineIndex > 0 ? "\n" : "") + sign + rowLine(row)}
|
|
146
|
+
</span>
|
|
147
|
+
);
|
|
148
|
+
})}
|
|
149
|
+
</text>
|
|
150
|
+
</line-number>
|
|
151
|
+
{segment.annotation ? (
|
|
152
|
+
<text>
|
|
153
|
+
<span fg={tokens.textDim}>{" "}</span>
|
|
154
|
+
<span fg={segment.annotation.id === focusedAnnotationId ? tokens.text : tokens.accent}>
|
|
155
|
+
◆ {truncateToSingleLine(segment.annotation.body, 70)}
|
|
156
|
+
</span>
|
|
157
|
+
</text>
|
|
158
|
+
) : null}
|
|
159
|
+
</box>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function DiffSheet({ rows, cursor, annotations, focusedAnnotationId, theme }: DiffSheetProps): React.ReactNode {
|
|
164
|
+
const tokens = useComponentTheme(theme);
|
|
165
|
+
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
166
|
+
|
|
167
|
+
const annotatedByRow = useMemo(() => {
|
|
168
|
+
const byRow = new Map<number, Annotation>();
|
|
169
|
+
for (const annotation of annotations) {
|
|
170
|
+
const rowIndex = rows.findIndex(
|
|
171
|
+
(row) => row.text === annotation.anchor.quote && (row.kind === "ctx" || row.kind === "add" || row.kind === "del"),
|
|
172
|
+
);
|
|
173
|
+
if (rowIndex !== -1) byRow.set(rowIndex, annotation);
|
|
174
|
+
}
|
|
175
|
+
return byRow;
|
|
176
|
+
}, [rows, annotations]);
|
|
177
|
+
|
|
178
|
+
const segments = useMemo(() => segmentRows(rows, annotatedByRow), [rows, annotatedByRow]);
|
|
179
|
+
|
|
180
|
+
// content y offset per row index, for cursor-following scroll
|
|
181
|
+
const rowOffsets = useMemo(() => {
|
|
182
|
+
const offsets: number[] = [];
|
|
183
|
+
let contentY = 0;
|
|
184
|
+
for (const segment of segments) {
|
|
185
|
+
if (segment.kind === "header") {
|
|
186
|
+
offsets[segment.rowIndex] = contentY;
|
|
187
|
+
contentY += 1;
|
|
188
|
+
} else {
|
|
189
|
+
segment.rows.forEach((_, lineIndex) => {
|
|
190
|
+
offsets[segment.firstRowIndex + lineIndex] = contentY + lineIndex;
|
|
191
|
+
});
|
|
192
|
+
contentY += segment.rows.length + (segment.annotation ? 1 : 0);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return offsets;
|
|
196
|
+
}, [segments]);
|
|
197
|
+
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
const scrollbox = scrollRef.current;
|
|
200
|
+
const cursorOffset = rowOffsets[cursor];
|
|
201
|
+
if (!scrollbox || cursorOffset === undefined) return;
|
|
202
|
+
const viewportHeight = Math.max(1, scrollbox.height);
|
|
203
|
+
if (cursorOffset < scrollbox.scrollTop + 2) {
|
|
204
|
+
scrollbox.scrollTo({ x: 0, y: Math.max(0, cursorOffset - 2) });
|
|
205
|
+
} else if (cursorOffset > scrollbox.scrollTop + viewportHeight - 3) {
|
|
206
|
+
scrollbox.scrollTo({ x: 0, y: cursorOffset - viewportHeight + 3 });
|
|
207
|
+
}
|
|
208
|
+
}, [cursor, rowOffsets]);
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
|
|
212
|
+
<scrollbox ref={scrollRef} style={{ flexGrow: 1 }} focused={false}>
|
|
213
|
+
{segments.map((segment, segmentIndex) => {
|
|
214
|
+
if (segment.kind === "header") {
|
|
215
|
+
const isCursor = segment.rowIndex === cursor;
|
|
216
|
+
if (segment.row.kind === "file") {
|
|
217
|
+
return (
|
|
218
|
+
<text key={segmentIndex} fg={tokens.text} bg={isCursor ? tokens.cursorBg : tokens.panel}>
|
|
219
|
+
{isCursor ? "▎" : " "}■ {rowLine(segment.row)}
|
|
220
|
+
</text>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return (
|
|
224
|
+
<text key={segmentIndex} fg={tokens.blue} bg={isCursor ? tokens.cursorBg : undefined}>
|
|
225
|
+
{isCursor ? "▎" : " "}
|
|
226
|
+
{rowLine(segment.row)}
|
|
227
|
+
</text>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return (
|
|
231
|
+
<DiffChunk
|
|
232
|
+
key={segmentIndex}
|
|
233
|
+
segment={segment}
|
|
234
|
+
cursor={cursor}
|
|
235
|
+
focusedAnnotationId={focusedAnnotationId}
|
|
236
|
+
theme={theme}
|
|
237
|
+
/>
|
|
238
|
+
);
|
|
239
|
+
})}
|
|
240
|
+
</scrollbox>
|
|
241
|
+
</box>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { InboxList } from "./InboxList";
|
|
5
|
+
import { fixtureDiffSession, fixturePlanSession } from "./story-fixtures";
|
|
6
|
+
|
|
7
|
+
export const meta: StoryMeta = { title: "InboxList" };
|
|
8
|
+
|
|
9
|
+
export const TwoPending: Story = {
|
|
10
|
+
render: () => <InboxList inbox={[fixturePlanSession(), fixtureDiffSession()]} cursor={0} />,
|
|
11
|
+
expectedColors: [DARK.accent, DARK.cursorBg],
|
|
12
|
+
size: { width: 80, height: 12 },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const Empty: Story = {
|
|
16
|
+
render: () => <InboxList inbox={[]} cursor={0} />,
|
|
17
|
+
size: { width: 80, height: 8 },
|
|
18
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pending-review inbox: a cursor-driven session list. Selection stays
|
|
3
|
+
* with the keyboard grammar; the list only renders the snapshot.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
8
|
+
import type { Theme } from "../theme";
|
|
9
|
+
import { useComponentTheme } from "./theme-context";
|
|
10
|
+
import { StatusBar } from "./primitives/StatusBar";
|
|
11
|
+
|
|
12
|
+
export interface InboxListProps {
|
|
13
|
+
inbox: ReviewSession[];
|
|
14
|
+
cursor: number;
|
|
15
|
+
theme?: Theme;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function InboxList({ inbox, cursor, theme }: InboxListProps): React.ReactNode {
|
|
19
|
+
const tokens = useComponentTheme(theme);
|
|
20
|
+
return (
|
|
21
|
+
<box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: tokens.bg, padding: 1 }}>
|
|
22
|
+
<text fg={tokens.accent}>cueloop · inbox ({inbox.length} pending)</text>
|
|
23
|
+
<text> </text>
|
|
24
|
+
{inbox.length === 0 ? (
|
|
25
|
+
<text fg={tokens.textDim}>nothing waiting for review</text>
|
|
26
|
+
) : (
|
|
27
|
+
inbox.map((session, index) => (
|
|
28
|
+
<text
|
|
29
|
+
key={session.id}
|
|
30
|
+
fg={index === cursor ? tokens.text : tokens.textMuted}
|
|
31
|
+
bg={index === cursor ? tokens.cursorBg : undefined}
|
|
32
|
+
>
|
|
33
|
+
{index === cursor ? "▸ " : " "}
|
|
34
|
+
{session.artifact.meta.title ?? session.id} · {session.workspace.branch} · {session.artifact.type}
|
|
35
|
+
</text>
|
|
36
|
+
))
|
|
37
|
+
)}
|
|
38
|
+
<box style={{ flexGrow: 1 }} />
|
|
39
|
+
<StatusBar theme={theme}>j/k move · ⏎ open · q quit</StatusBar>
|
|
40
|
+
</box>
|
|
41
|
+
);
|
|
42
|
+
}
|