@cueloop/client 0.1.0-alpha.65 → 0.1.0-alpha.66
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.diff.test.tsx +8 -73
- package/src/App.plan-review.test.tsx +4 -185
- package/src/App.prototype.test.tsx +1 -8
- package/src/App.readonly.test.tsx +6 -28
- package/src/App.submit-confirm.test.tsx +19 -72
- package/src/App.test.tsx +70 -24
- package/src/App.tsx +177 -254
- package/src/App.walk.test.tsx +1 -1
- package/src/app-screens.tsx +40 -51
- package/src/app-view-model.ts +2 -4
- package/src/components/AnnotationCard.stories.tsx +1 -1
- package/src/components/AppShell.stories.tsx +84 -0
- package/src/components/AppShell.tsx +127 -0
- package/src/components/Breadcrumb.stories.tsx +1 -1
- package/src/components/ChangesColumn.stories.tsx +39 -0
- package/src/components/ChangesColumn.tsx +147 -0
- package/src/components/CodeBlock.stories.tsx +1 -1
- package/src/components/CompletionOverlay.stories.tsx +1 -1
- package/src/components/ConfirmCard.stories.tsx +1 -1
- package/src/components/ConfirmCard.tsx +3 -4
- package/src/components/ConfirmDialog.stories.tsx +1 -1
- package/src/components/DiffSheet.stories.tsx +1 -1
- package/src/components/InboxList.stories.tsx +23 -8
- package/src/components/InboxList.tsx +45 -19
- package/src/components/KeybindsDialog.stories.tsx +1 -1
- package/src/components/MarkerPopover.stories.tsx +1 -1
- package/src/components/MenuBar.stories.tsx +3 -12
- package/src/components/MenuBar.tsx +25 -56
- package/src/components/PromptDialog.stories.tsx +1 -1
- package/src/components/PrototypeSheet.stories.tsx +1 -1
- package/src/components/ReviewPanel.stories.tsx +1 -1
- package/src/components/ReviewRail.stories.tsx +1 -1
- package/src/components/SettingsDialog.stories.tsx +7 -1
- package/src/components/SettingsDialog.tsx +112 -66
- package/src/components/SettingsRows.stories.tsx +1 -1
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.tsx +49 -0
- package/src/components/ThreadView.slash.test.tsx +113 -0
- package/src/components/ThreadView.stories.tsx +1 -1
- package/src/components/ThreadView.tsx +99 -11
- package/src/components/ThreadsSidebar.stories.tsx +36 -0
- package/src/components/ThreadsSidebar.tsx +57 -0
- package/src/components/Toast.stories.tsx +1 -1
- package/src/components/TreePane.stories.tsx +1 -1
- package/src/components/WalkWizard.stories.tsx +1 -1
- package/src/components/__snapshots__/stories.test.tsx.snap +348 -138
- package/src/components/agent-launcher.stories.tsx +1 -1
- package/src/components/file-tree.test.ts +45 -0
- package/src/components/file-tree.ts +57 -0
- package/src/components/pages/Workspace.stories.tsx +181 -0
- package/src/components/primitives/Button.stories.tsx +1 -1
- package/src/components/primitives/Card.stories.tsx +1 -1
- package/src/components/primitives/Dialog.stories.tsx +1 -1
- package/src/components/primitives/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +43 -0
- package/src/components/primitives/StatusBar.stories.tsx +1 -1
- package/src/components/primitives/Tabs.stories.tsx +1 -1
- package/src/components/primitives/Toolbar.stories.tsx +1 -1
- package/src/components/primitives/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +87 -0
- package/src/components/primitives/icons.ts +38 -0
- package/src/components/primitives/tree-model.test.ts +99 -0
- package/src/components/primitives/tree-model.ts +137 -0
- package/src/components/quick-actions-editor.stories.tsx +1 -1
- package/src/components/session-tree.test.ts +114 -0
- package/src/components/session-tree.ts +94 -0
- package/src/components/stories-app.tsx +142 -82
- package/src/components/story-tree.test.ts +100 -0
- package/src/components/story.ts +55 -0
- package/src/components/theme-context.stories.tsx +1 -1
- package/src/session-controller.ts +9 -4
- package/src/theme.ts +4 -4
- package/src/use-settings-dialog.tsx +9 -0
- package/src/App.review-panel.test.tsx +0 -140
- package/src/App.tree.test.tsx +0 -224
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { ShellHeader } from "./ShellHeader";
|
|
5
|
+
import { NERD } from "./primitives/icons";
|
|
6
|
+
|
|
7
|
+
export const meta: StoryMeta = { title: "Layout/ShellHeader" };
|
|
8
|
+
|
|
9
|
+
function Frame({ children }: { children: React.ReactNode }): React.ReactNode {
|
|
10
|
+
return <box style={{ width: "100%", backgroundColor: DARK.panel }}>{children}</box>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const ReviewSurface: Story = {
|
|
14
|
+
render: () => (
|
|
15
|
+
<Frame>
|
|
16
|
+
<ShellHeader
|
|
17
|
+
leftIcons={[{ glyph: NERD.settings }]}
|
|
18
|
+
leftLabel="Read Cueloop Repository"
|
|
19
|
+
tabs={[{ label: "Changes", active: true }]}
|
|
20
|
+
rightIcons={[
|
|
21
|
+
{ glyph: NERD.expand },
|
|
22
|
+
{ glyph: NERD.diff, active: true },
|
|
23
|
+
{ glyph: NERD.listTree },
|
|
24
|
+
]}
|
|
25
|
+
onToggleSidebar={() => {}}
|
|
26
|
+
onToggleInspector={() => {}}
|
|
27
|
+
/>
|
|
28
|
+
</Frame>
|
|
29
|
+
),
|
|
30
|
+
expectedColors: [DARK.accent, DARK.textMuted],
|
|
31
|
+
size: { width: 120, height: 3 },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const InspectorCollapsed: Story = {
|
|
35
|
+
render: () => (
|
|
36
|
+
<Frame>
|
|
37
|
+
<ShellHeader
|
|
38
|
+
leftIcons={[{ glyph: NERD.settings }]}
|
|
39
|
+
leftLabel="Read Cueloop Repository"
|
|
40
|
+
rightIcons={[{ glyph: NERD.search }, { glyph: NERD.expand }]}
|
|
41
|
+
onToggleSidebar={() => {}}
|
|
42
|
+
sidebarOpen={false}
|
|
43
|
+
onToggleInspector={() => {}}
|
|
44
|
+
inspectorOpen={false}
|
|
45
|
+
/>
|
|
46
|
+
</Frame>
|
|
47
|
+
),
|
|
48
|
+
expectedColors: [DARK.textMuted],
|
|
49
|
+
size: { width: 120, height: 3 },
|
|
50
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// The app shell's top bar: left panel toggle + icons + label, an optional title, right icons + panel toggle.
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { DARK, type Theme } from "../theme";
|
|
5
|
+
import { NERD } from "./primitives/icons";
|
|
6
|
+
|
|
7
|
+
export interface ShellTab {
|
|
8
|
+
label: string;
|
|
9
|
+
active?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// A header icon; active paints it in the brand accent to mark the current view or mode.
|
|
13
|
+
export interface ShellIcon {
|
|
14
|
+
glyph: string;
|
|
15
|
+
active?: boolean;
|
|
16
|
+
onPress?: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ShellHeaderProps {
|
|
20
|
+
leftIcons?: readonly ShellIcon[];
|
|
21
|
+
leftLabel?: string;
|
|
22
|
+
titleIcon?: string;
|
|
23
|
+
title?: string;
|
|
24
|
+
tabs?: readonly ShellTab[];
|
|
25
|
+
rightIcons?: readonly ShellIcon[];
|
|
26
|
+
sidebarOpen?: boolean;
|
|
27
|
+
onToggleSidebar?: () => void;
|
|
28
|
+
inspectorOpen?: boolean;
|
|
29
|
+
onToggleInspector?: () => void;
|
|
30
|
+
theme?: Theme;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function IconView({
|
|
34
|
+
icon,
|
|
35
|
+
tokens,
|
|
36
|
+
marginRight,
|
|
37
|
+
}: {
|
|
38
|
+
icon: ShellIcon;
|
|
39
|
+
tokens: Theme;
|
|
40
|
+
marginRight: number;
|
|
41
|
+
}): React.ReactNode {
|
|
42
|
+
const color = icon.active ? tokens.accent : tokens.textMuted;
|
|
43
|
+
|
|
44
|
+
// alignSelf center keeps the clickable box on the same row as sibling icons
|
|
45
|
+
return (
|
|
46
|
+
<box onMouseUp={icon.onPress} style={{ alignSelf: "center", marginRight }}>
|
|
47
|
+
<text fg={color}>{icon.glyph}</text>
|
|
48
|
+
</box>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function ShellHeader({
|
|
53
|
+
leftIcons = [],
|
|
54
|
+
leftLabel,
|
|
55
|
+
titleIcon,
|
|
56
|
+
title,
|
|
57
|
+
tabs = [],
|
|
58
|
+
rightIcons = [],
|
|
59
|
+
sidebarOpen = true,
|
|
60
|
+
onToggleSidebar,
|
|
61
|
+
inspectorOpen = true,
|
|
62
|
+
onToggleInspector,
|
|
63
|
+
theme,
|
|
64
|
+
}: ShellHeaderProps): React.ReactNode {
|
|
65
|
+
const tokens = theme ?? DARK;
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<box
|
|
69
|
+
style={{
|
|
70
|
+
flexDirection: "row",
|
|
71
|
+
height: "100%",
|
|
72
|
+
alignItems: "center",
|
|
73
|
+
paddingLeft: 2,
|
|
74
|
+
paddingRight: 2,
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
<box style={{ flexDirection: "row", alignItems: "center" }}>
|
|
78
|
+
{onToggleSidebar !== undefined ? (
|
|
79
|
+
<IconView
|
|
80
|
+
icon={{
|
|
81
|
+
glyph: sidebarOpen ? NERD.sidebarLeft : NERD.sidebarLeftOff,
|
|
82
|
+
onPress: onToggleSidebar,
|
|
83
|
+
}}
|
|
84
|
+
tokens={tokens}
|
|
85
|
+
marginRight={3}
|
|
86
|
+
/>
|
|
87
|
+
) : null}
|
|
88
|
+
{leftIcons.map((icon, index) => (
|
|
89
|
+
<IconView
|
|
90
|
+
key={`left-${index}-${icon.glyph}`}
|
|
91
|
+
icon={icon}
|
|
92
|
+
tokens={tokens}
|
|
93
|
+
marginRight={3}
|
|
94
|
+
/>
|
|
95
|
+
))}
|
|
96
|
+
{leftLabel !== undefined ? (
|
|
97
|
+
<text fg={tokens.textMuted}>
|
|
98
|
+
{leftLabel}
|
|
99
|
+
{" "}
|
|
100
|
+
</text>
|
|
101
|
+
) : null}
|
|
102
|
+
{titleIcon !== undefined ? <text fg={tokens.textDim}>{titleIcon} </text> : null}
|
|
103
|
+
{title !== undefined ? <text fg={tokens.text}>{title}</text> : null}
|
|
104
|
+
</box>
|
|
105
|
+
<box style={{ flexGrow: 1 }} />
|
|
106
|
+
<box style={{ flexDirection: "row", alignItems: "center" }}>
|
|
107
|
+
{tabs.map((tab, index) => (
|
|
108
|
+
<text
|
|
109
|
+
key={`tab-${index}-${tab.label}`}
|
|
110
|
+
fg={tab.active ? tokens.accent : tokens.textMuted}
|
|
111
|
+
>
|
|
112
|
+
{tab.label}
|
|
113
|
+
{" "}
|
|
114
|
+
</text>
|
|
115
|
+
))}
|
|
116
|
+
{rightIcons.map((icon, index) => (
|
|
117
|
+
<IconView
|
|
118
|
+
key={`right-${index}-${icon.glyph}`}
|
|
119
|
+
icon={icon}
|
|
120
|
+
tokens={tokens}
|
|
121
|
+
marginRight={3}
|
|
122
|
+
/>
|
|
123
|
+
))}
|
|
124
|
+
{onToggleInspector !== undefined ? (
|
|
125
|
+
<IconView
|
|
126
|
+
icon={{
|
|
127
|
+
glyph: inspectorOpen ? NERD.sidebarRight : NERD.sidebarRightOff,
|
|
128
|
+
onPress: onToggleInspector,
|
|
129
|
+
}}
|
|
130
|
+
tokens={tokens}
|
|
131
|
+
marginRight={0}
|
|
132
|
+
/>
|
|
133
|
+
) : null}
|
|
134
|
+
</box>
|
|
135
|
+
</box>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { ThreadFooter } from "./ThreadFooter";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "Chrome/ThreadFooter" };
|
|
7
|
+
|
|
8
|
+
function Frame({ children }: { children: React.ReactNode }): React.ReactNode {
|
|
9
|
+
return <box style={{ width: "100%", backgroundColor: DARK.panel }}>{children}</box>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const Default: Story = {
|
|
13
|
+
render: () => (
|
|
14
|
+
<Frame>
|
|
15
|
+
<ThreadFooter repo="cueloop" branch="main" onSubmit={() => {}} />
|
|
16
|
+
</Frame>
|
|
17
|
+
),
|
|
18
|
+
expectedColors: [DARK.accent, DARK.textMuted],
|
|
19
|
+
size: { width: 80, height: 1 },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const ReadOnly: Story = {
|
|
23
|
+
render: () => (
|
|
24
|
+
<Frame>
|
|
25
|
+
<ThreadFooter repo="cueloop" branch="design/tui" canSubmit={false} onSubmit={() => {}} />
|
|
26
|
+
</Frame>
|
|
27
|
+
),
|
|
28
|
+
expectedColors: [DARK.textDim],
|
|
29
|
+
size: { width: 80, height: 1 },
|
|
30
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The thread's bottom band: a header-like bar shown only under the always-visible
|
|
2
|
+
// thread column. It carries the repo/branch context on the left and a written,
|
|
3
|
+
// clickable "Send message" control on the right.
|
|
4
|
+
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { DARK, type Theme } from "../theme";
|
|
7
|
+
import { NERD } from "./primitives/icons";
|
|
8
|
+
|
|
9
|
+
export interface ThreadFooterProps {
|
|
10
|
+
repo: string;
|
|
11
|
+
branch: string;
|
|
12
|
+
onSubmit?: () => void;
|
|
13
|
+
/** When false the send control dims and does not fire (e.g. an observer). */
|
|
14
|
+
canSubmit?: boolean;
|
|
15
|
+
theme?: Theme;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function ThreadFooter({
|
|
19
|
+
repo,
|
|
20
|
+
branch,
|
|
21
|
+
onSubmit,
|
|
22
|
+
canSubmit = true,
|
|
23
|
+
theme,
|
|
24
|
+
}: ThreadFooterProps): React.ReactNode {
|
|
25
|
+
const tokens = theme ?? DARK;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<box
|
|
29
|
+
style={{
|
|
30
|
+
flexDirection: "row",
|
|
31
|
+
height: 1,
|
|
32
|
+
alignItems: "center",
|
|
33
|
+
paddingLeft: 1,
|
|
34
|
+
paddingRight: 1,
|
|
35
|
+
backgroundColor: tokens.panel,
|
|
36
|
+
}}
|
|
37
|
+
>
|
|
38
|
+
<text>
|
|
39
|
+
<span fg={tokens.blue}>{`${NERD.folderClosed} `}</span>
|
|
40
|
+
<span fg={tokens.textMuted}>{repo}</span>
|
|
41
|
+
<span fg={tokens.textDim}>{` / ${branch}`}</span>
|
|
42
|
+
</text>
|
|
43
|
+
<box style={{ flexGrow: 1 }} />
|
|
44
|
+
<box onMouseUp={canSubmit ? onSubmit : undefined}>
|
|
45
|
+
<text fg={canSubmit ? tokens.accent : tokens.textDim}>send message</text>
|
|
46
|
+
</box>
|
|
47
|
+
</box>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** Inline skill completion in the composer (#25): the trailing "/word" token and its match. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import React from "react";
|
|
5
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
6
|
+
import type { QuickAction } from "../config";
|
|
7
|
+
import { settle, typeText } from "../test-support";
|
|
8
|
+
import { buildDisplay, marksByDisplay } from "../view-plan";
|
|
9
|
+
import { fixturePlanSession } from "./story-fixtures";
|
|
10
|
+
import { inlineSlashToken, resolveInlineSuggestion, ThreadView } from "./ThreadView";
|
|
11
|
+
|
|
12
|
+
const SKILLS: QuickAction[] = [
|
|
13
|
+
{ prompt: "typescript magician" },
|
|
14
|
+
{ prompt: "implement" },
|
|
15
|
+
{ prompt: "write discoverable code" },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const PLAN = "# Plan\n\nRefine the store before the rewrite lands.\n";
|
|
19
|
+
|
|
20
|
+
async function mountComposer() {
|
|
21
|
+
const display = buildDisplay(PLAN, undefined);
|
|
22
|
+
const setup = await testRender(
|
|
23
|
+
<ThreadView
|
|
24
|
+
session={fixturePlanSession({
|
|
25
|
+
artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
|
|
26
|
+
})}
|
|
27
|
+
display={display}
|
|
28
|
+
marks={marksByDisplay([], display)}
|
|
29
|
+
quickActions={SKILLS}
|
|
30
|
+
observer={false}
|
|
31
|
+
onAnnotate={() => {}}
|
|
32
|
+
onReply={() => {}}
|
|
33
|
+
onUpdateAnnotation={() => {}}
|
|
34
|
+
onExit={() => {}}
|
|
35
|
+
/>,
|
|
36
|
+
{ width: 72, height: 20 },
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
await settle(setup);
|
|
40
|
+
await settle(setup);
|
|
41
|
+
|
|
42
|
+
return setup;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("inlineSlashToken", () => {
|
|
46
|
+
test("finds the trailing slash token when text precedes it", () => {
|
|
47
|
+
expect(inlineSlashToken("please run /type")).toBe("/type");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("ignores a draft that is itself a leading slash (that is the palette)", () => {
|
|
51
|
+
expect(inlineSlashToken("/type")).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("is newline-safe: a token at the start of a later line still resolves", () => {
|
|
55
|
+
expect(inlineSlashToken("first line\n/impl")).toBe("/impl");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("returns null when the trailing word is not a slash token", () => {
|
|
59
|
+
expect(inlineSlashToken("just some prose")).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a bare slash mid-sentence is a valid, empty-query token", () => {
|
|
63
|
+
expect(inlineSlashToken("run /")).toBe("/");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("resolveInlineSuggestion", () => {
|
|
68
|
+
test("offers the fuzzy closest skill for the trailing token", () => {
|
|
69
|
+
const inline = resolveInlineSuggestion(false, "run /type", SKILLS);
|
|
70
|
+
|
|
71
|
+
expect(inline?.token).toBe("/type");
|
|
72
|
+
expect(inline?.suggestion.name).toBe("typescript-magician");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("prefers a prefix match over a subsequence match", () => {
|
|
76
|
+
const inline = resolveInlineSuggestion(false, "run /impl", SKILLS);
|
|
77
|
+
|
|
78
|
+
expect(inline?.suggestion.name).toBe("implement");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("stays silent while the palette owns a leading slash", () => {
|
|
82
|
+
expect(resolveInlineSuggestion(true, "/type", SKILLS)).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("returns null when nothing matches the token", () => {
|
|
86
|
+
expect(resolveInlineSuggestion(false, "run /zzzz", SKILLS)).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("inline completion in the composer", () => {
|
|
91
|
+
test("typing an inline slash shows the tab-hint, and tab completes to the full name", async () => {
|
|
92
|
+
// Arrange - open a composer and type text with a trailing slash token
|
|
93
|
+
const setup = await mountComposer();
|
|
94
|
+
|
|
95
|
+
await typeText(setup, "run /type");
|
|
96
|
+
|
|
97
|
+
// Assert - the hint offers the closest skill, marked with the tab glyph
|
|
98
|
+
const withHint = setup.captureCharFrame();
|
|
99
|
+
|
|
100
|
+
expect(withHint).toContain("⇥");
|
|
101
|
+
expect(withHint).toContain("/typescript-magician");
|
|
102
|
+
|
|
103
|
+
// Act - tab completes the token
|
|
104
|
+
setup.mockInput.pressKey("TAB");
|
|
105
|
+
await settle(setup);
|
|
106
|
+
|
|
107
|
+
// Assert - the hint is gone and the composer now carries the full skill name
|
|
108
|
+
const completed = setup.captureCharFrame();
|
|
109
|
+
|
|
110
|
+
expect(completed).not.toContain("⇥");
|
|
111
|
+
expect(completed).toContain("typescript-magician");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -5,7 +5,7 @@ import type { Story, StoryMeta } from "./story";
|
|
|
5
5
|
import { fixtureDisplay, fixtureMarks, fixturePlanSession } from "./story-fixtures";
|
|
6
6
|
import { ThreadView } from "./ThreadView";
|
|
7
7
|
|
|
8
|
-
export const meta: StoryMeta = { title: "ThreadView" };
|
|
8
|
+
export const meta: StoryMeta = { title: "Surfaces/ThreadView" };
|
|
9
9
|
|
|
10
10
|
export const InlineThreads: Story = {
|
|
11
11
|
render: () => (
|
|
@@ -432,6 +432,38 @@ function slashFilter(items: SlashItem[], query: string): SlashItem[] {
|
|
|
432
432
|
return scored.toSorted((left, right) => right.score - left.score).map((entry) => entry.item);
|
|
433
433
|
}
|
|
434
434
|
|
|
435
|
+
/**
|
|
436
|
+
* A skill invoked mid-sentence: the trailing "/word" token when text already
|
|
437
|
+
* precedes it (a draft that starts with "/" is the palette, not an inline
|
|
438
|
+
* completion). Newline-safe, since the token may sit at the start of a new line.
|
|
439
|
+
*/
|
|
440
|
+
export function inlineSlashToken(text: string): string | null {
|
|
441
|
+
if (text.startsWith("/")) return null;
|
|
442
|
+
const match = /(?:^|\s)(\/[a-zA-Z0-9-]*)$/.exec(text);
|
|
443
|
+
|
|
444
|
+
return match ? match[1]! : null;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
interface InlineSlash {
|
|
448
|
+
token: string;
|
|
449
|
+
suggestion: SlashItem;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** The inline completion state: the trailing "/word" and its closest skill, or null. */
|
|
453
|
+
export function resolveInlineSuggestion(
|
|
454
|
+
slashActive: boolean,
|
|
455
|
+
text: string,
|
|
456
|
+
quickActions: QuickAction[],
|
|
457
|
+
): InlineSlash | null {
|
|
458
|
+
if (slashActive) return null;
|
|
459
|
+
const token = inlineSlashToken(text);
|
|
460
|
+
|
|
461
|
+
if (token === null) return null;
|
|
462
|
+
const suggestion = slashFilter(slashItemsFrom(quickActions), token.slice(1))[0];
|
|
463
|
+
|
|
464
|
+
return suggestion ? { token, suggestion } : null;
|
|
465
|
+
}
|
|
466
|
+
|
|
435
467
|
function SlashList({
|
|
436
468
|
items,
|
|
437
469
|
selected,
|
|
@@ -470,6 +502,44 @@ function SlashList({
|
|
|
470
502
|
);
|
|
471
503
|
}
|
|
472
504
|
|
|
505
|
+
/** Below the composer: the palette list for a leading "/", else the inline tab-hint, else nothing. */
|
|
506
|
+
function ComposerPalette({
|
|
507
|
+
slashActive,
|
|
508
|
+
slashItems,
|
|
509
|
+
slashIndex,
|
|
510
|
+
inline,
|
|
511
|
+
tokens,
|
|
512
|
+
}: {
|
|
513
|
+
slashActive: boolean;
|
|
514
|
+
slashItems: SlashItem[];
|
|
515
|
+
slashIndex: number;
|
|
516
|
+
inline: InlineSlash | null;
|
|
517
|
+
tokens: Theme;
|
|
518
|
+
}): React.ReactNode {
|
|
519
|
+
if (slashActive) {
|
|
520
|
+
return (
|
|
521
|
+
<box style={{ flexDirection: "column", marginLeft: 3 }}>
|
|
522
|
+
<SlashList
|
|
523
|
+
items={slashItems}
|
|
524
|
+
selected={Math.min(slashIndex, Math.max(0, slashItems.length - 1))}
|
|
525
|
+
tokens={tokens}
|
|
526
|
+
/>
|
|
527
|
+
</box>
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
if (inline === null) return null;
|
|
531
|
+
|
|
532
|
+
return (
|
|
533
|
+
<box style={{ flexDirection: "row", marginLeft: 3 }}>
|
|
534
|
+
<text>
|
|
535
|
+
<span fg={tokens.textDim}>{"⇥ "}</span>
|
|
536
|
+
<span fg={tokens.accent}>{`/${inline.suggestion.name}`}</span>
|
|
537
|
+
<span fg={tokens.textDim}>{` ${inline.suggestion.description}`}</span>
|
|
538
|
+
</text>
|
|
539
|
+
</box>
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
473
543
|
/* ------------------------------------------------------------ discussion card */
|
|
474
544
|
|
|
475
545
|
/** One card row group with its own accent-edge color, one per voice. */
|
|
@@ -949,6 +1019,9 @@ export function ThreadView({
|
|
|
949
1019
|
const slashItems = slashActive
|
|
950
1020
|
? slashFilter(slashItemsFrom(quickActions), composeText.slice(1).trim())
|
|
951
1021
|
: [];
|
|
1022
|
+
// inline skill completion: the trailing "/word" token and its closest match,
|
|
1023
|
+
// offered as a hint below the composer that tab completes to the full name
|
|
1024
|
+
const inlineSlash = resolveInlineSuggestion(slashActive, composeText, quickActions);
|
|
952
1025
|
|
|
953
1026
|
const saveComment = (body: string): void => {
|
|
954
1027
|
// the body saves verbatim - typed newlines are the author's choice;
|
|
@@ -1071,6 +1144,17 @@ export function ThreadView({
|
|
|
1071
1144
|
return false;
|
|
1072
1145
|
};
|
|
1073
1146
|
|
|
1147
|
+
/** Tab completes the trailing "/word" to the matched skill's full name, then a space to chain. */
|
|
1148
|
+
const handleInlineSlashKey = (key: KeyEvent, activeCompose: ComposeState): boolean => {
|
|
1149
|
+
if (inlineSlash === null || key.name !== "tab") return false;
|
|
1150
|
+
const cut = composeText.length - inlineSlash.token.length;
|
|
1151
|
+
const completed = `${composeText.slice(0, cut)}/${inlineSlash.suggestion.name} `;
|
|
1152
|
+
|
|
1153
|
+
openCompose({ ...activeCompose, seed: completed });
|
|
1154
|
+
|
|
1155
|
+
return true;
|
|
1156
|
+
};
|
|
1157
|
+
|
|
1074
1158
|
/** Pre-mount window: buffer printables, honor a fast cmd+enter or newline. */
|
|
1075
1159
|
const handlePremountKey = (key: KeyEvent, activeCompose: ComposeState): void => {
|
|
1076
1160
|
if (key.name === "return") {
|
|
@@ -1112,6 +1196,7 @@ export function ThreadView({
|
|
|
1112
1196
|
return closeCompose();
|
|
1113
1197
|
}
|
|
1114
1198
|
if (handleSlashKey(key, activeCompose)) return;
|
|
1199
|
+
if (handleInlineSlashKey(key, activeCompose)) return;
|
|
1115
1200
|
if (!composerReady.current) handlePremountKey(key, activeCompose);
|
|
1116
1201
|
};
|
|
1117
1202
|
|
|
@@ -1291,15 +1376,18 @@ export function ThreadView({
|
|
|
1291
1376
|
onInput={setComposeText}
|
|
1292
1377
|
/>
|
|
1293
1378
|
) : null;
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1379
|
+
// one node below the composer: the palette list for a leading "/", otherwise the
|
|
1380
|
+
// inline completion hint. Renders null when neither applies, so it always pushes.
|
|
1381
|
+
const paletteNode = (
|
|
1382
|
+
<ComposerPalette
|
|
1383
|
+
key="composer-palette"
|
|
1384
|
+
slashActive={slashActive}
|
|
1385
|
+
slashItems={slashItems}
|
|
1386
|
+
slashIndex={slashIndex}
|
|
1387
|
+
inline={inlineSlash}
|
|
1388
|
+
tokens={tokens}
|
|
1389
|
+
/>
|
|
1390
|
+
);
|
|
1303
1391
|
|
|
1304
1392
|
const foldedSummaryFor = (discussion: Discussion): React.ReactNode => (
|
|
1305
1393
|
<box key={discussion.key} style={{ flexDirection: "row", marginTop: 1, marginLeft: 2 }}>
|
|
@@ -1468,7 +1556,7 @@ export function ThreadView({
|
|
|
1468
1556
|
continue;
|
|
1469
1557
|
}
|
|
1470
1558
|
nodes.push(discussionCardFor(discussion, composingHere));
|
|
1471
|
-
if (composingHere
|
|
1559
|
+
if (composingHere) nodes.push(paletteNode);
|
|
1472
1560
|
}
|
|
1473
1561
|
if (composeHere && compose.discussionKey === null && composerNode) {
|
|
1474
1562
|
const anchoredHere = compose.span ? endsInLine(compose.span.end.char) : isLastLine;
|
|
@@ -1481,7 +1569,7 @@ export function ThreadView({
|
|
|
1481
1569
|
segments={[{ color: palette.cardEdge, node: composerNode }]}
|
|
1482
1570
|
/>,
|
|
1483
1571
|
);
|
|
1484
|
-
|
|
1572
|
+
nodes.push(paletteNode);
|
|
1485
1573
|
}
|
|
1486
1574
|
}
|
|
1487
1575
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { ThreadsSidebar } from "./ThreadsSidebar";
|
|
5
|
+
import { groupInbox } from "./session-tree";
|
|
6
|
+
import { fixtureDiffSession, fixturePlanSession } from "./story-fixtures";
|
|
7
|
+
|
|
8
|
+
export const meta: StoryMeta = { title: "Surfaces/ThreadsSidebar" };
|
|
9
|
+
|
|
10
|
+
function Frame({ children }: { children: React.ReactNode }): React.ReactNode {
|
|
11
|
+
return <box style={{ width: "100%", height: "100%", flexDirection: "row" }}>{children}</box>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const plan = fixturePlanSession();
|
|
15
|
+
const projectPlan = {
|
|
16
|
+
...plan,
|
|
17
|
+
workspace: { ...plan.workspace, rootCommit: "root-1", remote: "git@github.com:acme/widget.git" },
|
|
18
|
+
};
|
|
19
|
+
const { rows, ordered } = groupInbox([projectPlan, fixtureDiffSession()]);
|
|
20
|
+
|
|
21
|
+
export const Open: Story = {
|
|
22
|
+
render: () => (
|
|
23
|
+
<Frame>
|
|
24
|
+
<ThreadsSidebar
|
|
25
|
+
open
|
|
26
|
+
rows={rows}
|
|
27
|
+
cursor={0}
|
|
28
|
+
activeId={ordered[0]!.id}
|
|
29
|
+
onSelect={() => {}}
|
|
30
|
+
theme={DARK}
|
|
31
|
+
/>
|
|
32
|
+
</Frame>
|
|
33
|
+
),
|
|
34
|
+
expectedColors: [DARK.accent, DARK.border],
|
|
35
|
+
size: { width: 60, height: 12 },
|
|
36
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The left Projects and Threads column: a collapsible, scrollable tree beside the
|
|
2
|
+
// thread. A click jumps to that thread; the open thread stays highlighted. Renders
|
|
3
|
+
// nothing when collapsed so the thread reclaims the width.
|
|
4
|
+
|
|
5
|
+
import React from "react";
|
|
6
|
+
import type { Theme } from "../theme";
|
|
7
|
+
import { InboxList } from "./InboxList";
|
|
8
|
+
import type { InboxRow } from "./session-tree";
|
|
9
|
+
|
|
10
|
+
export interface ThreadsSidebarProps {
|
|
11
|
+
open: boolean;
|
|
12
|
+
rows: InboxRow[];
|
|
13
|
+
cursor: number;
|
|
14
|
+
/** The open thread's id, highlighted in the list. */
|
|
15
|
+
activeId?: string;
|
|
16
|
+
onSelect: (sessionId: string) => void;
|
|
17
|
+
/** Ask to delete a thread (the selected row's [delete] button). */
|
|
18
|
+
onRequestDelete?: (id: string, title: string) => void;
|
|
19
|
+
width?: number;
|
|
20
|
+
theme?: Theme;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ThreadsSidebar({
|
|
24
|
+
open,
|
|
25
|
+
rows,
|
|
26
|
+
cursor,
|
|
27
|
+
activeId,
|
|
28
|
+
onSelect,
|
|
29
|
+
onRequestDelete,
|
|
30
|
+
width = 30,
|
|
31
|
+
theme,
|
|
32
|
+
}: ThreadsSidebarProps): React.ReactNode {
|
|
33
|
+
if (!open) return null;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<box
|
|
37
|
+
style={{
|
|
38
|
+
width,
|
|
39
|
+
flexDirection: "column",
|
|
40
|
+
borderStyle: "single",
|
|
41
|
+
border: ["right"],
|
|
42
|
+
borderColor: theme?.border,
|
|
43
|
+
}}
|
|
44
|
+
>
|
|
45
|
+
<scrollbox style={{ flexGrow: 1 }} focused={false}>
|
|
46
|
+
<InboxList
|
|
47
|
+
rows={rows}
|
|
48
|
+
cursor={cursor}
|
|
49
|
+
activeId={activeId}
|
|
50
|
+
onSelect={onSelect}
|
|
51
|
+
onRequestDelete={onRequestDelete}
|
|
52
|
+
theme={theme}
|
|
53
|
+
/>
|
|
54
|
+
</scrollbox>
|
|
55
|
+
</box>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -3,7 +3,7 @@ import { DARK } from "../theme";
|
|
|
3
3
|
import type { Story, StoryMeta } from "./story";
|
|
4
4
|
import { Toast } from "./Toast";
|
|
5
5
|
|
|
6
|
-
export const meta: StoryMeta = { title: "Toast" };
|
|
6
|
+
export const meta: StoryMeta = { title: "Overlays/Toast" };
|
|
7
7
|
|
|
8
8
|
export const ShareLink: Story = {
|
|
9
9
|
render: () => <Toast title="share link copied" body="ssh review-4f2a@cueloop.dev" />,
|
|
@@ -4,7 +4,7 @@ import type { Story, StoryMeta } from "./story";
|
|
|
4
4
|
import { TreePane } from "./TreePane";
|
|
5
5
|
import type { TreeRow } from "../tree-view";
|
|
6
6
|
|
|
7
|
-
export const meta: StoryMeta = { title: "TreePane" };
|
|
7
|
+
export const meta: StoryMeta = { title: "Surfaces/TreePane" };
|
|
8
8
|
|
|
9
9
|
const callbacks = {
|
|
10
10
|
onSelect: () => {},
|
|
@@ -4,7 +4,7 @@ import type { WalkFile } from "../walk";
|
|
|
4
4
|
import type { Story, StoryMeta } from "./story";
|
|
5
5
|
import { WalkWizard } from "./WalkWizard";
|
|
6
6
|
|
|
7
|
-
export const meta: StoryMeta = { title: "WalkWizard" };
|
|
7
|
+
export const meta: StoryMeta = { title: "Overlays/WalkWizard" };
|
|
8
8
|
|
|
9
9
|
const FILES: WalkFile[] = [
|
|
10
10
|
{
|