@cueloop/client 0.1.0-alpha.11 → 0.1.0-alpha.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/App.autoclose.test.tsx +24 -35
- package/src/App.diff.test.tsx +5 -18
- package/src/App.export.test.tsx +8 -21
- package/src/App.plan-review.test.tsx +21 -52
- package/src/App.readonly.test.tsx +3 -18
- package/src/App.rendering.test.tsx +5 -13
- package/src/App.submit-confirm.test.tsx +7 -25
- package/src/App.test.tsx +13 -52
- package/src/App.tsx +197 -910
- package/src/components/AnnotationCard.stories.tsx +75 -0
- package/src/components/AnnotationCard.tsx +136 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +38 -0
- package/src/components/CodeBlock.stories.tsx +25 -0
- package/src/components/CodeBlock.tsx +68 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +50 -0
- package/src/components/ComposeBar.stories.tsx +18 -0
- package/src/components/ComposeBar.tsx +54 -0
- package/src/components/ConfirmCard.stories.tsx +22 -0
- package/src/components/ConfirmCard.tsx +130 -0
- package/src/components/DiffSheet.stories.tsx +44 -0
- package/src/components/DiffSheet.tsx +243 -0
- package/src/components/InboxList.stories.tsx +18 -0
- package/src/components/InboxList.tsx +42 -0
- package/src/components/PlanSheet.stories.tsx +81 -0
- package/src/components/PlanSheet.tsx +309 -0
- package/src/components/ReviewRail.stories.tsx +103 -0
- package/src/components/ReviewRail.tsx +158 -0
- package/src/components/SettingsDialog.stories.tsx +63 -0
- package/src/components/SettingsDialog.tsx +132 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +92 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1189 -0
- package/src/components/format.ts +6 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +44 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +58 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +52 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +22 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +97 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/stories-app.tsx +94 -0
- package/src/components/stories.test.tsx +51 -0
- package/src/components/story-fixtures.ts +90 -0
- package/src/components/story.ts +66 -0
- package/src/components/syntax-highlight.ts +57 -0
- package/src/components/theme-context.stories.tsx +41 -0
- package/src/components/theme-context.tsx +30 -0
- package/src/key-bindings.test.ts +103 -0
- package/src/key-bindings.ts +332 -0
- package/src/keymap.ts +8 -2
- package/src/run.ts +3 -1
- package/src/session-controller.ts +28 -7
- package/src/test-support.ts +87 -0
- package/src/theme.ts +3 -0
- package/src/view.test.ts +35 -13
- package/src/view.ts +41 -64
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { AnnotationCard } from "./AnnotationCard";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "AnnotationCard" };
|
|
7
|
+
|
|
8
|
+
const draftCallbacks = { onInput: () => {}, onSave: () => {}, onCancel: () => {} };
|
|
9
|
+
|
|
10
|
+
export const CommentDraft: Story = {
|
|
11
|
+
render: () => (
|
|
12
|
+
<AnnotationCard kind="comment" quote="persists sessions" draft={{ text: "Which daemon?", ...draftCallbacks }} />
|
|
13
|
+
),
|
|
14
|
+
expectedColors: [DARK.accent, DARK.elevated],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const SuggestionDraft: Story = {
|
|
18
|
+
render: () => (
|
|
19
|
+
<AnnotationCard kind="suggestion" quote="move the store" draft={{ text: "", ...draftCallbacks }} />
|
|
20
|
+
),
|
|
21
|
+
expectedColors: [DARK.green],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const SavedSelected: Story = {
|
|
25
|
+
render: () => (
|
|
26
|
+
<AnnotationCard
|
|
27
|
+
kind="comment"
|
|
28
|
+
quote="persists sessions"
|
|
29
|
+
saved={{
|
|
30
|
+
body: "Which daemon owns this?",
|
|
31
|
+
isSelected: true,
|
|
32
|
+
isOrphan: false,
|
|
33
|
+
isBlocking: false,
|
|
34
|
+
editing: null,
|
|
35
|
+
onPress: () => {},
|
|
36
|
+
}}
|
|
37
|
+
/>
|
|
38
|
+
),
|
|
39
|
+
expectedColors: [DARK.elevated],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const SavedOrphanBlocking: Story = {
|
|
43
|
+
render: () => (
|
|
44
|
+
<AnnotationCard
|
|
45
|
+
kind="comment"
|
|
46
|
+
quote="a removed passage"
|
|
47
|
+
saved={{
|
|
48
|
+
body: "The passage is gone.",
|
|
49
|
+
isSelected: false,
|
|
50
|
+
isOrphan: true,
|
|
51
|
+
isBlocking: true,
|
|
52
|
+
editing: null,
|
|
53
|
+
onPress: () => {},
|
|
54
|
+
}}
|
|
55
|
+
/>
|
|
56
|
+
),
|
|
57
|
+
expectedColors: [DARK.red],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const SavedEditing: Story = {
|
|
61
|
+
render: () => (
|
|
62
|
+
<AnnotationCard
|
|
63
|
+
kind="suggestion"
|
|
64
|
+
quote="move the store"
|
|
65
|
+
saved={{
|
|
66
|
+
body: "old body",
|
|
67
|
+
isSelected: true,
|
|
68
|
+
isOrphan: false,
|
|
69
|
+
isBlocking: false,
|
|
70
|
+
editing: { text: "new body", ...draftCallbacks },
|
|
71
|
+
onPress: () => {},
|
|
72
|
+
}}
|
|
73
|
+
/>
|
|
74
|
+
),
|
|
75
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One annotation component in three modes: a draft composer (bordered card
|
|
3
|
+
* inline in the document flow), a saved rail card, and the rail card editing
|
|
4
|
+
* its body in place. Compose, saved, and re-edit share this rendering path,
|
|
5
|
+
* so the kind-color and quote treatment can never drift between them.
|
|
6
|
+
*
|
|
7
|
+
* Bodies are multiline: the composer is a textarea (wrap, undo/redo,
|
|
8
|
+
* shift+⏎ for a new line) while plain ⏎ stays the save key.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import React, { useEffect, useRef } from "react";
|
|
12
|
+
import type { KeyBinding, TextareaRenderable } from "@opentui/core";
|
|
13
|
+
import type { Theme } from "../theme";
|
|
14
|
+
import { useComponentTheme } from "./theme-context";
|
|
15
|
+
import { Card } from "./primitives/Card";
|
|
16
|
+
import { Button } from "./primitives/Button";
|
|
17
|
+
import { Toolbar } from "./primitives/Toolbar";
|
|
18
|
+
import { truncate } from "./format";
|
|
19
|
+
|
|
20
|
+
export interface AnnotationDraft {
|
|
21
|
+
text: string;
|
|
22
|
+
onInput: (text: string) => void;
|
|
23
|
+
onSave: () => void;
|
|
24
|
+
onCancel: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AnnotationSaved {
|
|
28
|
+
body: string;
|
|
29
|
+
isSelected: boolean;
|
|
30
|
+
isOrphan: boolean;
|
|
31
|
+
isBlocking: boolean;
|
|
32
|
+
/** Non-null while the card body is being rewritten in place. */
|
|
33
|
+
editing: AnnotationDraft | null;
|
|
34
|
+
onPress: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AnnotationCardProps {
|
|
38
|
+
id?: string;
|
|
39
|
+
kind: string;
|
|
40
|
+
quote: string;
|
|
41
|
+
draft?: AnnotationDraft;
|
|
42
|
+
saved?: AnnotationSaved;
|
|
43
|
+
theme?: Theme;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** ⏎ saves (the grammar owns it); shift+⏎ makes the body multiline. */
|
|
47
|
+
const COMPOSE_KEY_BINDINGS: KeyBinding[] = [
|
|
48
|
+
{ name: "return", action: "submit" },
|
|
49
|
+
{ name: "return", shift: true, action: "newline" },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
function editorRows(text: string): number {
|
|
53
|
+
return Math.min(4, Math.max(1, text.split("\n").length));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function DraftEditor({ draft, theme }: { draft: AnnotationDraft; theme?: Theme }): React.ReactNode {
|
|
57
|
+
const tokens = useComponentTheme(theme);
|
|
58
|
+
const editorRef = useRef<TextareaRenderable | null>(null);
|
|
59
|
+
// re-edit opens with the caret after the existing body, like a text field
|
|
60
|
+
const initialTextLength = useRef(draft.text.length);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const editor = editorRef.current;
|
|
63
|
+
if (editor) editor.cursorOffset = initialTextLength.current;
|
|
64
|
+
}, []);
|
|
65
|
+
return (
|
|
66
|
+
<>
|
|
67
|
+
<textarea
|
|
68
|
+
ref={editorRef}
|
|
69
|
+
focused
|
|
70
|
+
initialValue={draft.text}
|
|
71
|
+
placeholder="write a note..."
|
|
72
|
+
keyBindings={COMPOSE_KEY_BINDINGS}
|
|
73
|
+
onContentChange={() => {
|
|
74
|
+
const editor = editorRef.current;
|
|
75
|
+
if (editor) draft.onInput(editor.plainText);
|
|
76
|
+
}}
|
|
77
|
+
style={{
|
|
78
|
+
height: editorRows(draft.text),
|
|
79
|
+
backgroundColor: tokens.elevated,
|
|
80
|
+
focusedBackgroundColor: tokens.elevated,
|
|
81
|
+
textColor: tokens.text,
|
|
82
|
+
focusedTextColor: tokens.text,
|
|
83
|
+
}}
|
|
84
|
+
/>
|
|
85
|
+
<Toolbar>
|
|
86
|
+
<Button variant="solid" marginRight={2} onPress={draft.onSave} theme={theme}>
|
|
87
|
+
{" Save ⏎ "}
|
|
88
|
+
</Button>
|
|
89
|
+
<Button onPress={draft.onCancel} theme={theme}>
|
|
90
|
+
{" Cancel esc "}
|
|
91
|
+
</Button>
|
|
92
|
+
</Toolbar>
|
|
93
|
+
</>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function AnnotationCard({ id, kind, quote, draft, saved, theme }: AnnotationCardProps): React.ReactNode {
|
|
98
|
+
const tokens = useComponentTheme(theme);
|
|
99
|
+
const kindColor = kind === "suggestion" ? tokens.green : tokens.accent;
|
|
100
|
+
if (draft) {
|
|
101
|
+
const verb = kind === "suggestion" ? "suggest replacement for" : "comment on";
|
|
102
|
+
return (
|
|
103
|
+
<Card
|
|
104
|
+
title={` ${verb} "${truncate(quote, 40)}" `}
|
|
105
|
+
contentRows={editorRows(draft.text) + 1}
|
|
106
|
+
borderColor={kindColor}
|
|
107
|
+
marginLeft={2}
|
|
108
|
+
marginRight={2}
|
|
109
|
+
theme={theme}
|
|
110
|
+
>
|
|
111
|
+
<DraftEditor draft={draft} theme={theme} />
|
|
112
|
+
</Card>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const card = saved!;
|
|
116
|
+
return (
|
|
117
|
+
<box
|
|
118
|
+
id={id}
|
|
119
|
+
style={{ flexDirection: "column", marginBottom: 1, backgroundColor: card.isSelected ? tokens.elevated : undefined }}
|
|
120
|
+
onMouseUp={card.onPress}
|
|
121
|
+
>
|
|
122
|
+
<text fg={card.isSelected ? tokens.text : kindColor}>
|
|
123
|
+
{card.isSelected ? "▸ " : " "}
|
|
124
|
+
{kind.toUpperCase()}
|
|
125
|
+
{card.isBlocking ? <span fg={tokens.red}> · BLOCKING</span> : null}
|
|
126
|
+
<span fg={tokens.textDim}>{card.isOrphan ? " · ORPHANED" : " · pending"}</span>
|
|
127
|
+
</text>
|
|
128
|
+
<text fg={tokens.textDim}> "{truncate(quote, 26)}"</text>
|
|
129
|
+
{card.editing ? (
|
|
130
|
+
<DraftEditor draft={card.editing} theme={theme} />
|
|
131
|
+
) : (
|
|
132
|
+
<text fg={card.isOrphan ? tokens.textDim : tokens.textMuted}> {truncate(card.body, 28)}</text>
|
|
133
|
+
)}
|
|
134
|
+
</box>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { Breadcrumb } from "./Breadcrumb";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "Breadcrumb" };
|
|
7
|
+
|
|
8
|
+
export const SessionHeader: Story = {
|
|
9
|
+
render: () => (
|
|
10
|
+
<Breadcrumb
|
|
11
|
+
items={[
|
|
12
|
+
{ label: "cueloop", tone: "accent" },
|
|
13
|
+
{ label: "Migration Plan · rev 2", tone: "dim" },
|
|
14
|
+
{ label: "resolved: approve", tone: "green" },
|
|
15
|
+
]}
|
|
16
|
+
/>
|
|
17
|
+
),
|
|
18
|
+
expectedColors: [DARK.accent, DARK.green, DARK.panel],
|
|
19
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-row breadcrumb trail: toned segments joined by " · " on the panel
|
|
3
|
+
* surface. The app header is a breadcrumb (product · artifact · revision)
|
|
4
|
+
* followed by state badges; each item picks a semantic tone.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React from "react";
|
|
8
|
+
import type { Theme } from "../theme";
|
|
9
|
+
import { useComponentTheme } from "./theme-context";
|
|
10
|
+
|
|
11
|
+
export type BreadcrumbTone = "accent" | "dim" | "text" | "green";
|
|
12
|
+
|
|
13
|
+
export interface BreadcrumbItem {
|
|
14
|
+
label: string;
|
|
15
|
+
tone?: BreadcrumbTone;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface BreadcrumbProps {
|
|
19
|
+
items: BreadcrumbItem[];
|
|
20
|
+
theme?: Theme;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function Breadcrumb({ items, theme }: BreadcrumbProps): React.ReactNode {
|
|
24
|
+
const tokens = useComponentTheme(theme);
|
|
25
|
+
const toneColor = (tone?: BreadcrumbTone): string =>
|
|
26
|
+
tone === "accent" ? tokens.accent : tone === "green" ? tokens.green : tone === "text" ? tokens.text : tokens.textDim;
|
|
27
|
+
return (
|
|
28
|
+
<box style={{ height: 1, backgroundColor: tokens.panel, paddingLeft: 1, flexDirection: "row" }}>
|
|
29
|
+
<text fg={tokens.text}>
|
|
30
|
+
{items.map((item, index) => (
|
|
31
|
+
<span key={index} fg={toneColor(item.tone)}>
|
|
32
|
+
{index === 0 ? item.label : ` · ${item.label}`}
|
|
33
|
+
</span>
|
|
34
|
+
))}
|
|
35
|
+
</text>
|
|
36
|
+
</box>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { CodeBlock } from "./CodeBlock";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "CodeBlock" };
|
|
7
|
+
|
|
8
|
+
const SNIPPET = `export function gate(full: number) {
|
|
9
|
+
// threshold
|
|
10
|
+
return full < 0.6 ? "blocked" : "clear";
|
|
11
|
+
}`;
|
|
12
|
+
|
|
13
|
+
export const Typescript: Story = {
|
|
14
|
+
render: () => <CodeBlock language="ts" content={SNIPPET} />,
|
|
15
|
+
expectedColors: [DARK.elevated],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const CursorAnnotatedEdited: Story = {
|
|
19
|
+
render: () => <CodeBlock language="ts" content={SNIPPET} isCursor isAnnotated changeTag="edited" marginTop={1} />,
|
|
20
|
+
expectedColors: [DARK.accent],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const UnknownLanguage: Story = {
|
|
24
|
+
render: () => <CodeBlock language="brainfog" content={"plain verbatim lines\n with indentation"} />,
|
|
25
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fenced code block: verbatim lines in a contained elevated box, highlighted
|
|
3
|
+
* by the native code renderable (tree-sitter). Unstyled text draws first, so
|
|
4
|
+
* the content never flashes empty while highlights resolve; unknown languages
|
|
5
|
+
* stay unstyled.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React from "react";
|
|
9
|
+
import type { Theme } from "../theme";
|
|
10
|
+
import { useComponentTheme } from "./theme-context";
|
|
11
|
+
import { filetypeFor, syntaxStyleFor } from "./syntax-highlight";
|
|
12
|
+
|
|
13
|
+
export interface CodeBlockProps {
|
|
14
|
+
id?: string;
|
|
15
|
+
/** Markdown fence info string ("tsx", "python", ...). */
|
|
16
|
+
language?: string;
|
|
17
|
+
content: string;
|
|
18
|
+
isCursor?: boolean;
|
|
19
|
+
/** Vertical rhythm above the block. */
|
|
20
|
+
marginTop?: number;
|
|
21
|
+
isAnnotated?: boolean;
|
|
22
|
+
/** Working-copy change tag rendered after the language label. */
|
|
23
|
+
changeTag?: "cut" | "new" | "edited";
|
|
24
|
+
theme?: Theme;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function CodeBlock({
|
|
28
|
+
id,
|
|
29
|
+
language,
|
|
30
|
+
content,
|
|
31
|
+
isCursor = false,
|
|
32
|
+
marginTop = 0,
|
|
33
|
+
isAnnotated = false,
|
|
34
|
+
changeTag,
|
|
35
|
+
theme,
|
|
36
|
+
}: CodeBlockProps): React.ReactNode {
|
|
37
|
+
const tokens = useComponentTheme(theme);
|
|
38
|
+
const tagColor = changeTag === "cut" ? tokens.red : changeTag === "new" ? tokens.green : tokens.accent;
|
|
39
|
+
return (
|
|
40
|
+
<box id={id} style={{ flexDirection: "column", marginTop }}>
|
|
41
|
+
<text>
|
|
42
|
+
<span fg={isCursor ? tokens.accent : tokens.textDim}>{isCursor ? "▎ " : " "}</span>
|
|
43
|
+
<span fg={tokens.textDim}>{language ?? "code"}</span>
|
|
44
|
+
{isAnnotated ? <span fg={tokens.accent}> ◆</span> : null}
|
|
45
|
+
{changeTag ? <span fg={tagColor}> [{changeTag}]</span> : null}
|
|
46
|
+
</text>
|
|
47
|
+
<box
|
|
48
|
+
style={{
|
|
49
|
+
flexDirection: "column",
|
|
50
|
+
backgroundColor: tokens.elevated,
|
|
51
|
+
paddingLeft: 2,
|
|
52
|
+
paddingRight: 2,
|
|
53
|
+
paddingTop: 1,
|
|
54
|
+
paddingBottom: 1,
|
|
55
|
+
marginLeft: 2,
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
<code
|
|
59
|
+
content={content}
|
|
60
|
+
filetype={filetypeFor(language)}
|
|
61
|
+
syntaxStyle={syntaxStyleFor(tokens)}
|
|
62
|
+
selectable={false}
|
|
63
|
+
style={{ wrapMode: "none", fg: tokens.textMuted, bg: tokens.elevated }}
|
|
64
|
+
/>
|
|
65
|
+
</box>
|
|
66
|
+
</box>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -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,50 @@
|
|
|
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
|
+
{status ? <text fg={tokens.textDim}>{status}</text> : null}
|
|
40
|
+
<text> </text>
|
|
41
|
+
{completion.phase === "counting" ? (
|
|
42
|
+
<text fg={tokens.textDim}>
|
|
43
|
+
closing in {completion.remaining}s{returnsTo ? ` - returning to ${returnsTo}` : ""} · ⏎ now · esc stay
|
|
44
|
+
</text>
|
|
45
|
+
) : (
|
|
46
|
+
<text fg={tokens.textDim}>⏎ close · a always close after submit (3s) · esc stay</text>
|
|
47
|
+
)}
|
|
48
|
+
</box>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -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 { truncate } from "./format";
|
|
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"} “{truncate(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,22 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
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 { MouseEvent, SelectRenderable } from "@opentui/core";
|
|
11
|
+
import type { VerdictKind } from "@cueloop/schema";
|
|
12
|
+
import type { Theme } from "../theme";
|
|
13
|
+
import { useComponentTheme } from "./theme-context";
|
|
14
|
+
import { Card } from "./primitives/Card";
|
|
15
|
+
import { Button } from "./primitives/Button";
|
|
16
|
+
import { Toolbar } from "./primitives/Toolbar";
|
|
17
|
+
|
|
18
|
+
export const VERDICTS: VerdictKind[] = ["comment", "approve", "request_changes"];
|
|
19
|
+
|
|
20
|
+
/** Selector words in the confirm card - one word per verdict. */
|
|
21
|
+
export const VERDICT_LABEL: Record<VerdictKind, string> = {
|
|
22
|
+
comment: "Comment",
|
|
23
|
+
approve: "Approve",
|
|
24
|
+
request_changes: "Changes",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export interface ConfirmCardProps {
|
|
28
|
+
verdict: VerdictKind;
|
|
29
|
+
summary: string;
|
|
30
|
+
annotationCount: number;
|
|
31
|
+
blockingCount: number;
|
|
32
|
+
onInput: (summary: string) => void;
|
|
33
|
+
onSelectVerdict: (verdict: VerdictKind) => void;
|
|
34
|
+
onSubmit: () => void;
|
|
35
|
+
onCancel: () => void;
|
|
36
|
+
theme?: Theme;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Counts, spacer, 3-row verdict selector, spacer, summary input, spacer, buttons. */
|
|
40
|
+
const CONFIRM_CONTENT_ROWS = 9;
|
|
41
|
+
|
|
42
|
+
export function verdictColor(verdict: VerdictKind, tokens: Theme): string {
|
|
43
|
+
return verdict === "approve" ? tokens.green : verdict === "request_changes" ? tokens.red : tokens.blue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The verdict selector over the native select renderable. Selection stays
|
|
48
|
+
* controlled by the grammar (←/→ cycle the verdict); clicks map through the
|
|
49
|
+
* option row geometry. The selected verdict wears brackets and its color.
|
|
50
|
+
*/
|
|
51
|
+
function VerdictSelector({
|
|
52
|
+
verdict,
|
|
53
|
+
onSelectVerdict,
|
|
54
|
+
theme,
|
|
55
|
+
}: {
|
|
56
|
+
verdict: VerdictKind;
|
|
57
|
+
onSelectVerdict: (verdict: VerdictKind) => void;
|
|
58
|
+
theme?: Theme;
|
|
59
|
+
}): React.ReactNode {
|
|
60
|
+
const tokens = useComponentTheme(theme);
|
|
61
|
+
const selectedIndex = VERDICTS.indexOf(verdict);
|
|
62
|
+
const selectorRef = React.useRef<SelectRenderable | null>(null);
|
|
63
|
+
React.useEffect(() => {
|
|
64
|
+
selectorRef.current?.setSelectedIndex(selectedIndex);
|
|
65
|
+
}, [selectedIndex]);
|
|
66
|
+
const onMouseUp = (event: MouseEvent): void => {
|
|
67
|
+
const selector = selectorRef.current;
|
|
68
|
+
if (!selector) return;
|
|
69
|
+
const candidate = VERDICTS[event.y - selector.y];
|
|
70
|
+
if (candidate) onSelectVerdict(candidate);
|
|
71
|
+
};
|
|
72
|
+
return (
|
|
73
|
+
<select
|
|
74
|
+
ref={selectorRef}
|
|
75
|
+
options={VERDICTS.map((candidate) => ({
|
|
76
|
+
name: candidate === verdict ? `[${VERDICT_LABEL[candidate]}]` : ` ${VERDICT_LABEL[candidate]} `,
|
|
77
|
+
description: "",
|
|
78
|
+
value: candidate,
|
|
79
|
+
}))}
|
|
80
|
+
selectedIndex={selectedIndex}
|
|
81
|
+
showDescription={false}
|
|
82
|
+
showSelectionIndicator={false}
|
|
83
|
+
showScrollIndicator={false}
|
|
84
|
+
textColor={tokens.textDim}
|
|
85
|
+
selectedTextColor={verdictColor(verdict, tokens)}
|
|
86
|
+
selectedBackgroundColor={tokens.elevated}
|
|
87
|
+
backgroundColor={tokens.elevated}
|
|
88
|
+
onMouseUp={onMouseUp}
|
|
89
|
+
style={{ height: VERDICTS.length }}
|
|
90
|
+
/>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function ConfirmCard({
|
|
95
|
+
verdict,
|
|
96
|
+
summary,
|
|
97
|
+
annotationCount,
|
|
98
|
+
blockingCount,
|
|
99
|
+
onInput,
|
|
100
|
+
onSelectVerdict,
|
|
101
|
+
onSubmit,
|
|
102
|
+
onCancel,
|
|
103
|
+
theme,
|
|
104
|
+
}: ConfirmCardProps): React.ReactNode {
|
|
105
|
+
const tokens = useComponentTheme(theme);
|
|
106
|
+
return (
|
|
107
|
+
<Card
|
|
108
|
+
title=" submit review "
|
|
109
|
+
contentRows={CONFIRM_CONTENT_ROWS}
|
|
110
|
+
borderColor={tokens.accent}
|
|
111
|
+
marginRight={1}
|
|
112
|
+
theme={theme}
|
|
113
|
+
>
|
|
114
|
+
<text fg={tokens.textDim}>{`${annotationCount} annotations · ${blockingCount} blocking`}</text>
|
|
115
|
+
<box style={{ height: 1 }} />
|
|
116
|
+
<VerdictSelector verdict={verdict} onSelectVerdict={onSelectVerdict} theme={theme} />
|
|
117
|
+
<box style={{ height: 1 }} />
|
|
118
|
+
<input focused value={summary} onInput={onInput} placeholder="summary for the agent (optional)" />
|
|
119
|
+
<box style={{ height: 1 }} />
|
|
120
|
+
<Toolbar>
|
|
121
|
+
<Button variant="solid" marginRight={2} onPress={onSubmit} theme={theme}>
|
|
122
|
+
{" Submit "}
|
|
123
|
+
</Button>
|
|
124
|
+
<Button onPress={onCancel} theme={theme}>
|
|
125
|
+
{" Cancel "}
|
|
126
|
+
</Button>
|
|
127
|
+
</Toolbar>
|
|
128
|
+
</Card>
|
|
129
|
+
);
|
|
130
|
+
}
|