@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
|
@@ -1,109 +1,169 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The interactive stories catalog (`bun run stories`): a
|
|
3
|
-
* component modules (j/k
|
|
2
|
+
* The interactive stories catalog (`bun run stories`): a nested tree of
|
|
3
|
+
* component modules on the left (j/k to move, h/l to fold or open) and the
|
|
4
4
|
* selected story rendered live in the preview pane. The same story objects
|
|
5
5
|
* back the snapshot harness - what this TUI shows is what the tests assert.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import React, { useMemo, useState } from "react";
|
|
9
|
-
import { createCliRenderer } from "@opentui/core";
|
|
8
|
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
9
|
+
import { createCliRenderer, type ScrollBoxRenderable } from "@opentui/core";
|
|
10
10
|
import { createRoot, useKeyboard } from "@opentui/react";
|
|
11
11
|
import { DARK } from "../theme";
|
|
12
12
|
import { ThemeProvider } from "./theme-context";
|
|
13
|
-
import { loadStories, type LoadedStory } from "./story";
|
|
14
|
-
import {
|
|
13
|
+
import { buildStoryTree, loadStories, type LoadedStory } from "./story";
|
|
14
|
+
import { AppShell } from "./AppShell";
|
|
15
|
+
import { ShellHeader } from "./ShellHeader";
|
|
16
|
+
import { Tree } from "./primitives/Tree";
|
|
17
|
+
import { NERD } from "./primitives/icons";
|
|
18
|
+
import { allFolderIds, flattenTree } from "./primitives/tree-model";
|
|
15
19
|
|
|
16
|
-
|
|
17
|
-
stories,
|
|
18
|
-
onExit,
|
|
19
|
-
}: {
|
|
20
|
+
interface StoriesAppProps {
|
|
20
21
|
stories: LoadedStory[];
|
|
21
22
|
onExit: () => void;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function StoriesApp({ stories, onExit }: StoriesAppProps): React.ReactNode {
|
|
26
|
+
const treeNodes = useMemo(() => buildStoryTree(stories), [stories]);
|
|
27
|
+
const storiesById = useMemo(
|
|
28
|
+
() => new Map(stories.map((story) => [`${story.moduleTitle}/${story.storyName}`, story])),
|
|
25
29
|
[stories],
|
|
26
30
|
);
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
const firstStoryId = `${stories[0]!.moduleTitle}/${stories[0]!.storyName}`;
|
|
32
|
+
|
|
33
|
+
const [expandedIds, setExpandedIds] = useState<ReadonlySet<string>>(() =>
|
|
34
|
+
allFolderIds(treeNodes),
|
|
35
|
+
);
|
|
36
|
+
const [selectedId, setSelectedId] = useState(firstStoryId);
|
|
37
|
+
const [openedId, setOpenedId] = useState(firstStoryId);
|
|
38
|
+
const [treeOpen, setTreeOpen] = useState(true);
|
|
39
|
+
|
|
40
|
+
const rows = flattenTree(treeNodes, { expandedIds });
|
|
41
|
+
const selectedRow = rows.find((row) => row.id === selectedId) ?? rows[0];
|
|
42
|
+
const opened = storiesById.get(openedId);
|
|
43
|
+
const sidebarScroll = useRef<ScrollBoxRenderable | null>(null);
|
|
44
|
+
|
|
45
|
+
// keep the moving selection inside the scrolled viewport
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
try {
|
|
48
|
+
sidebarScroll.current?.scrollChildIntoView(`tree-row-${selectedId}`);
|
|
49
|
+
} catch {
|
|
50
|
+
// reveal is best-effort; the selection state is already correct
|
|
51
|
+
}
|
|
52
|
+
}, [selectedId]);
|
|
53
|
+
|
|
54
|
+
const openStory = (id: string): void => {
|
|
55
|
+
if (storiesById.has(id)) setOpenedId(id);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const toggleFolder = (id: string): void => {
|
|
59
|
+
setExpandedIds((current) => {
|
|
60
|
+
const next = new Set(current);
|
|
61
|
+
if (next.has(id)) next.delete(id);
|
|
62
|
+
else next.add(id);
|
|
63
|
+
|
|
64
|
+
return next;
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const expandFolder = (id: string): void => {
|
|
69
|
+
setExpandedIds((current) => new Set(current).add(id));
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const collapseFolder = (id: string): void => {
|
|
73
|
+
setExpandedIds((current) => {
|
|
74
|
+
const next = new Set(current);
|
|
75
|
+
next.delete(id);
|
|
76
|
+
|
|
77
|
+
return next;
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const moveSelection = (delta: number): void => {
|
|
82
|
+
const index = rows.findIndex((row) => row.id === selectedId);
|
|
83
|
+
const nextIndex = Math.max(0, Math.min(rows.length - 1, index + delta));
|
|
84
|
+
const nextRow = rows[nextIndex];
|
|
85
|
+
if (nextRow !== undefined) setSelectedId(nextRow.id);
|
|
86
|
+
};
|
|
32
87
|
|
|
33
88
|
useKeyboard((key) => {
|
|
34
89
|
if (key.name === "q") onExit();
|
|
35
|
-
else if (key.name === "j" || key.name === "down")
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
} else if (key.name === "l" || key.name === "right") {
|
|
42
|
-
setStoryIndex((index) => (index + 1) % moduleStories.length);
|
|
90
|
+
else if (key.name === "j" || key.name === "down") moveSelection(1);
|
|
91
|
+
else if (key.name === "k" || key.name === "up") moveSelection(-1);
|
|
92
|
+
else if (selectedRow === undefined) return;
|
|
93
|
+
else if (key.name === "l" || key.name === "right") {
|
|
94
|
+
if (selectedRow.isFolder) expandFolder(selectedRow.id);
|
|
95
|
+
else openStory(selectedRow.id);
|
|
43
96
|
} else if (key.name === "h" || key.name === "left") {
|
|
44
|
-
|
|
97
|
+
if (selectedRow.isFolder) collapseFolder(selectedRow.id);
|
|
98
|
+
} else if (key.name === "return" || key.name === "space") {
|
|
99
|
+
if (selectedRow.isFolder) toggleFolder(selectedRow.id);
|
|
100
|
+
else openStory(selectedRow.id);
|
|
45
101
|
}
|
|
46
102
|
});
|
|
47
103
|
|
|
48
104
|
return (
|
|
49
105
|
<ThemeProvider theme={DARK}>
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
flexDirection: "column"
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
{
|
|
78
|
-
<text
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
style={{
|
|
86
|
-
flexGrow: 1
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
106
|
+
<AppShell
|
|
107
|
+
theme={DARK}
|
|
108
|
+
mainHeader={
|
|
109
|
+
<ShellHeader
|
|
110
|
+
theme={DARK}
|
|
111
|
+
leftIcons={[{ glyph: NERD.settings }]}
|
|
112
|
+
leftLabel="cueloop storybook"
|
|
113
|
+
inspectorOpen={treeOpen}
|
|
114
|
+
onToggleInspector={treeOpen ? undefined : () => setTreeOpen(true)}
|
|
115
|
+
/>
|
|
116
|
+
}
|
|
117
|
+
inspectorHeader={
|
|
118
|
+
<ShellHeader
|
|
119
|
+
theme={DARK}
|
|
120
|
+
inspectorOpen={treeOpen}
|
|
121
|
+
onToggleInspector={() => setTreeOpen(false)}
|
|
122
|
+
/>
|
|
123
|
+
}
|
|
124
|
+
main={
|
|
125
|
+
opened !== undefined ? (
|
|
126
|
+
<scrollbox style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }} focused={false}>
|
|
127
|
+
{/* remount per story so component-local state never leaks across */}
|
|
128
|
+
<box key={openedId} style={{ flexDirection: "column" }}>
|
|
129
|
+
{opened.story.render()}
|
|
130
|
+
</box>
|
|
131
|
+
</scrollbox>
|
|
132
|
+
) : (
|
|
133
|
+
<box style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }}>
|
|
134
|
+
<text fg={DARK.textDim}>Select a story</text>
|
|
135
|
+
</box>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
inspectorWidth={34}
|
|
139
|
+
inspector={
|
|
140
|
+
treeOpen ? (
|
|
141
|
+
<box style={{ flexDirection: "column", flexGrow: 1, paddingTop: 1 }}>
|
|
142
|
+
<scrollbox ref={sidebarScroll} style={{ flexGrow: 1 }} focused={false}>
|
|
143
|
+
<Tree
|
|
144
|
+
nodes={treeNodes}
|
|
145
|
+
expandedIds={expandedIds}
|
|
146
|
+
selectedId={selectedId}
|
|
147
|
+
theme={DARK}
|
|
148
|
+
onSelect={(id) => {
|
|
149
|
+
setSelectedId(id);
|
|
150
|
+
openStory(id);
|
|
151
|
+
}}
|
|
152
|
+
onToggle={(id) => {
|
|
153
|
+
setSelectedId(id);
|
|
154
|
+
toggleFolder(id);
|
|
155
|
+
}}
|
|
156
|
+
/>
|
|
157
|
+
</scrollbox>
|
|
100
158
|
</box>
|
|
159
|
+
) : undefined
|
|
160
|
+
}
|
|
161
|
+
footer={
|
|
162
|
+
<box style={{ height: 1, paddingLeft: 1 }}>
|
|
163
|
+
<text fg={DARK.textDim}>j/k move · h/l fold or open · enter open · q quit</text>
|
|
101
164
|
</box>
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
<text fg={DARK.textDim}>j/k component · h/l story · q quit</text>
|
|
105
|
-
</box>
|
|
106
|
-
</box>
|
|
165
|
+
}
|
|
166
|
+
/>
|
|
107
167
|
</ThemeProvider>
|
|
108
168
|
);
|
|
109
169
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildStoryTree, type LoadedStory } from "./story";
|
|
3
|
+
import type { TreeNode } from "./primitives/tree-model";
|
|
4
|
+
|
|
5
|
+
/** A story object whose render is never invoked in these structural tests. */
|
|
6
|
+
const STUB = { render: () => null };
|
|
7
|
+
|
|
8
|
+
function story(moduleTitle: string, storyName: string): LoadedStory {
|
|
9
|
+
return { moduleTitle, storyName, story: STUB };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function childLabels(node: TreeNode): string[] {
|
|
13
|
+
return (node.children ?? []).map((child) => child.label);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("buildStoryTree", () => {
|
|
17
|
+
test("splits moduleTitle on / into nested folders keyed by their path", () => {
|
|
18
|
+
// Arrange
|
|
19
|
+
const stories = [story("Primitives/Tree", "Default"), story("Layout/AppShell", "Default")];
|
|
20
|
+
|
|
21
|
+
// Act
|
|
22
|
+
const tree = buildStoryTree(stories);
|
|
23
|
+
|
|
24
|
+
// Assert
|
|
25
|
+
expect(tree.map((node) => [node.id, node.label])).toEqual([
|
|
26
|
+
["Primitives", "Primitives"],
|
|
27
|
+
["Layout", "Layout"],
|
|
28
|
+
]);
|
|
29
|
+
expect(tree[0]!.children!.map((node) => node.id)).toEqual(["Primitives/Tree"]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("groups sibling components under one shared parent folder", () => {
|
|
33
|
+
// Arrange
|
|
34
|
+
const stories = [story("Primitives/Tree", "Default"), story("Primitives/Card", "Default")];
|
|
35
|
+
|
|
36
|
+
// Act
|
|
37
|
+
const tree = buildStoryTree(stories);
|
|
38
|
+
|
|
39
|
+
// Assert
|
|
40
|
+
expect(tree).toHaveLength(1);
|
|
41
|
+
expect(childLabels(tree[0]!)).toEqual(["Tree", "Card"]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("nests to the full depth of the moduleTitle path", () => {
|
|
45
|
+
// Arrange
|
|
46
|
+
const stories = [story("A/B/C", "Only")];
|
|
47
|
+
|
|
48
|
+
// Act
|
|
49
|
+
const tree = buildStoryTree(stories);
|
|
50
|
+
const a = tree[0]!;
|
|
51
|
+
const b = a.children![0]!;
|
|
52
|
+
|
|
53
|
+
// Assert
|
|
54
|
+
expect([a.id, b.id]).toEqual(["A", "A/B"]);
|
|
55
|
+
expect(b.children![0]!.id).toBe("A/B/C");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("adds each story as a leaf keyed `${moduleTitle}/${storyName}` under its component", () => {
|
|
59
|
+
// Arrange
|
|
60
|
+
const stories = [story("Primitives/Tree", "Empty"), story("Primitives/Tree", "Deep")];
|
|
61
|
+
|
|
62
|
+
// Act
|
|
63
|
+
const tree = buildStoryTree(stories);
|
|
64
|
+
const component = tree[0]!.children![0]!;
|
|
65
|
+
|
|
66
|
+
// Assert
|
|
67
|
+
expect(component.id).toBe("Primitives/Tree");
|
|
68
|
+
expect(component.children!.map((leaf) => [leaf.id, leaf.label])).toEqual([
|
|
69
|
+
["Primitives/Tree/Empty", "Empty"],
|
|
70
|
+
["Primitives/Tree/Deep", "Deep"],
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("hoists a component with a single story of its own name into one leaf row", () => {
|
|
75
|
+
// Arrange
|
|
76
|
+
const stories = [story("Primitives/Card", "Card")];
|
|
77
|
+
|
|
78
|
+
// Act
|
|
79
|
+
const tree = buildStoryTree(stories);
|
|
80
|
+
const hoisted = tree[0]!.children![0]!;
|
|
81
|
+
|
|
82
|
+
// Assert
|
|
83
|
+
expect(hoisted.children).toBeUndefined();
|
|
84
|
+
expect(hoisted.id).toBe("Primitives/Card/Card");
|
|
85
|
+
expect(hoisted.label).toBe("Card");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("keeps the folder when its single story differs from the component name", () => {
|
|
89
|
+
// Arrange
|
|
90
|
+
const stories = [story("Primitives/Card", "Default")];
|
|
91
|
+
|
|
92
|
+
// Act
|
|
93
|
+
const tree = buildStoryTree(stories);
|
|
94
|
+
const component = tree[0]!.children![0]!;
|
|
95
|
+
|
|
96
|
+
// Assert
|
|
97
|
+
expect(component.id).toBe("Primitives/Card");
|
|
98
|
+
expect(component.children!.map((leaf) => leaf.id)).toEqual(["Primitives/Card/Default"]);
|
|
99
|
+
});
|
|
100
|
+
});
|
package/src/components/story.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type React from "react";
|
|
10
|
+
import type { TreeNode } from "./primitives/tree-model";
|
|
10
11
|
|
|
11
12
|
export interface StoryMeta {
|
|
12
13
|
/** Catalog path, e.g. "primitives/Card". */
|
|
@@ -68,6 +69,60 @@ export async function loadStories(): Promise<LoadedStory[]> {
|
|
|
68
69
|
return loaded;
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
/** A folder node keeps its children array live so the builder can append to it. */
|
|
73
|
+
interface StoryTreeFolder extends TreeNode {
|
|
74
|
+
children: TreeNode[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A component folder holding one story of its own name reads as one row, not a folder wrapping a single leaf.
|
|
78
|
+
function hoistSingleStoryFolders(nodes: TreeNode[]): TreeNode[] {
|
|
79
|
+
return nodes.map((node) => {
|
|
80
|
+
if (node.children === undefined) return node;
|
|
81
|
+
const children = hoistSingleStoryFolders(node.children);
|
|
82
|
+
const onlyChild = children.length === 1 ? children[0] : undefined;
|
|
83
|
+
if (
|
|
84
|
+
onlyChild !== undefined &&
|
|
85
|
+
onlyChild.children === undefined &&
|
|
86
|
+
onlyChild.label === node.label
|
|
87
|
+
) {
|
|
88
|
+
return onlyChild;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { ...node, children };
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Nest the flat catalog into folder/component groups by splitting each
|
|
97
|
+
* moduleTitle on "/". Each story becomes a leaf keyed `${moduleTitle}/${storyName}`
|
|
98
|
+
* under its component node; a component holding a single story of its own name
|
|
99
|
+
* collapses so its row IS that story leaf.
|
|
100
|
+
*/
|
|
101
|
+
export function buildStoryTree(stories: LoadedStory[]): TreeNode[] {
|
|
102
|
+
const roots: TreeNode[] = [];
|
|
103
|
+
const foldersByPath = new Map<string, StoryTreeFolder>();
|
|
104
|
+
|
|
105
|
+
for (const { moduleTitle, storyName } of stories) {
|
|
106
|
+
let siblings = roots;
|
|
107
|
+
let path = "";
|
|
108
|
+
|
|
109
|
+
for (const segment of moduleTitle.split("/")) {
|
|
110
|
+
path = path === "" ? segment : `${path}/${segment}`;
|
|
111
|
+
let folder = foldersByPath.get(path);
|
|
112
|
+
if (folder === undefined) {
|
|
113
|
+
folder = { id: path, label: segment, children: [] };
|
|
114
|
+
foldersByPath.set(path, folder);
|
|
115
|
+
siblings.push(folder);
|
|
116
|
+
}
|
|
117
|
+
siblings = folder.children;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
siblings.push({ id: `${moduleTitle}/${storyName}`, label: storyName });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return hoistSingleStoryFolders(roots);
|
|
124
|
+
}
|
|
125
|
+
|
|
71
126
|
/** Component files that must carry stories: every .tsx that is not a story/test/prototype. */
|
|
72
127
|
export function componentFilesMissingStories(): string[] {
|
|
73
128
|
const componentGlob = new Bun.Glob("**/*.tsx");
|
|
@@ -4,7 +4,7 @@ import type { Story, StoryMeta } from "./story";
|
|
|
4
4
|
import { ThemeProvider } from "./theme-context";
|
|
5
5
|
import { Card } from "./primitives/Card";
|
|
6
6
|
|
|
7
|
-
export const meta: StoryMeta = { title: "
|
|
7
|
+
export const meta: StoryMeta = { title: "Foundations/Theme" };
|
|
8
8
|
|
|
9
9
|
/** A high-contrast variant to prove tokens flow through the provider. */
|
|
10
10
|
const HIGH_CONTRAST: Theme = { ...DARK, accent: "#00ffff", elevated: "#000000", border: "#ffffff" };
|
|
@@ -371,13 +371,17 @@ class Controller implements ReviewController {
|
|
|
371
371
|
const session = this.snapshot.session;
|
|
372
372
|
|
|
373
373
|
if (session && event.sessionId === session.id) void this.refreshSession(session.id);
|
|
374
|
-
|
|
374
|
+
// the Threads sidebar shows the inbox even on a direct open, so keep it fresh
|
|
375
|
+
void this.refreshInbox();
|
|
375
376
|
});
|
|
376
377
|
await client.subscribe();
|
|
378
|
+
// load the inbox in both cases so the sidebar can jump between threads
|
|
379
|
+
const inbox = await client.sessionList({ status: "pending" });
|
|
380
|
+
|
|
377
381
|
if (this.options.sessionId) {
|
|
378
|
-
this.update({ session: await client.sessionGet(this.options.sessionId) });
|
|
382
|
+
this.update({ session: await client.sessionGet(this.options.sessionId), inbox });
|
|
379
383
|
} else {
|
|
380
|
-
this.update({ inbox
|
|
384
|
+
this.update({ inbox });
|
|
381
385
|
}
|
|
382
386
|
} catch (err) {
|
|
383
387
|
this.update({ error: err instanceof Error ? err.message : String(err) });
|
|
@@ -406,7 +410,8 @@ class Controller implements ReviewController {
|
|
|
406
410
|
}
|
|
407
411
|
|
|
408
412
|
setStatus(message: string): void {
|
|
409
|
-
|
|
413
|
+
// the persistent status bar is gone; transient feedback surfaces as a toast
|
|
414
|
+
this.update({ status: message, toast: message ? { body: message } : null });
|
|
410
415
|
}
|
|
411
416
|
|
|
412
417
|
showToast(body: string, title?: string): void {
|
package/src/theme.ts
CHANGED
|
@@ -54,8 +54,8 @@ export const DARK: Theme = {
|
|
|
54
54
|
text: "#e4e6ec",
|
|
55
55
|
textMuted: "#b1b6c4",
|
|
56
56
|
textDim: "#6b7280",
|
|
57
|
-
accent: "#
|
|
58
|
-
accentInk: "#
|
|
57
|
+
accent: "#cba6f7",
|
|
58
|
+
accentInk: "#17171a",
|
|
59
59
|
green: "#62d96b",
|
|
60
60
|
red: "#f08080",
|
|
61
61
|
blue: "#a9c8f5",
|
|
@@ -80,8 +80,8 @@ export const LIGHT: Theme = {
|
|
|
80
80
|
text: "#1c1d24",
|
|
81
81
|
textMuted: "#4b5162",
|
|
82
82
|
textDim: "#868c9c",
|
|
83
|
-
accent: "#
|
|
84
|
-
accentInk: "#
|
|
83
|
+
accent: "#5b3ea6",
|
|
84
|
+
accentInk: "#fbfbfa",
|
|
85
85
|
green: "#2f8a3e",
|
|
86
86
|
red: "#c0392b",
|
|
87
87
|
blue: "#2f6fb0",
|
|
@@ -191,6 +191,12 @@ export function useSettingsDialog(params: {
|
|
|
191
191
|
/>
|
|
192
192
|
),
|
|
193
193
|
},
|
|
194
|
+
{
|
|
195
|
+
id: "keybinds",
|
|
196
|
+
name: "Keybinds",
|
|
197
|
+
description: "keyboard reference",
|
|
198
|
+
rows: [],
|
|
199
|
+
},
|
|
194
200
|
];
|
|
195
201
|
const settingsValues = {
|
|
196
202
|
autoClose: autoClose === "off" ? "off" : `${autoClose}s`,
|
|
@@ -219,6 +225,9 @@ export function useSettingsDialog(params: {
|
|
|
219
225
|
};
|
|
220
226
|
|
|
221
227
|
const onCategorySelect = (categoryId: string): void => {
|
|
228
|
+
// the nav tree carries a synthetic Settings group folder; clicking it must
|
|
229
|
+
// not become the active category, or the key handler dereferences nothing
|
|
230
|
+
if (!settingsCategories.some((category) => category.id === categoryId)) return;
|
|
222
231
|
setActionsExpandedIndex(null);
|
|
223
232
|
setSettingsNav({ categoryId, rowIndex: 0, zone: "body" });
|
|
224
233
|
};
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
/** The review panel end to end: the b key cycles expanded -> compact -> hidden -> expanded on the live App, and the collapse mode persists to the user config so it survives a restart. Rendering details are locked by the ReviewPanel stories; this suite proves the App wiring and the persistence. */
|
|
2
|
-
|
|
3
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
5
|
-
import { tmpdir } from "node:os";
|
|
6
|
-
import { join } from "node:path";
|
|
7
|
-
import React from "react";
|
|
8
|
-
import { testRender } from "@opentui/react/test-utils";
|
|
9
|
-
import { DaemonServer } from "@cueloop/daemon";
|
|
10
|
-
import type { ReviewSession } from "@cueloop/schema";
|
|
11
|
-
import { App } from "./App";
|
|
12
|
-
import { loadConfig } from "./config";
|
|
13
|
-
import { isolateUserConfig, settle, waitForText, waitForTextGone, pressKey } from "./test-support";
|
|
14
|
-
|
|
15
|
-
const PLAN = "# Plan\n\nShip the thing.\n";
|
|
16
|
-
|
|
17
|
-
let home: string;
|
|
18
|
-
let server: DaemonServer;
|
|
19
|
-
let session: ReviewSession;
|
|
20
|
-
let configPath: string;
|
|
21
|
-
let restoreUserConfig: () => void;
|
|
22
|
-
|
|
23
|
-
beforeEach(() => {
|
|
24
|
-
home = mkdtempSync(join(tmpdir(), "cueloop-panel-"));
|
|
25
|
-
configPath = join(home, "config.toml");
|
|
26
|
-
restoreUserConfig = isolateUserConfig(home, "config.toml");
|
|
27
|
-
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
28
|
-
server.start();
|
|
29
|
-
session = server.core.sessionCreate({
|
|
30
|
-
workspace: { repoRoot: "/repo", branch: "main" },
|
|
31
|
-
artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
afterEach(() => {
|
|
35
|
-
restoreUserConfig();
|
|
36
|
-
server.stop();
|
|
37
|
-
rmSync(home, { recursive: true, force: true });
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
async function renderApp() {
|
|
41
|
-
const setup = await testRender(<App home={home} sessionId={session.id} />, {
|
|
42
|
-
width: 120,
|
|
43
|
-
height: 30,
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
await waitForText(setup, "Review");
|
|
47
|
-
|
|
48
|
-
return setup;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
describe("review panel", () => {
|
|
52
|
-
test("ctrl+r cycles expanded -> compact -> hidden -> expanded", async () => {
|
|
53
|
-
// Arrange
|
|
54
|
-
const setup = await renderApp();
|
|
55
|
-
// each mode has its own signature: expanded draws the tab box and the
|
|
56
|
-
// submit button, compact keeps only the count strip, hidden draws nothing
|
|
57
|
-
const railSignature = () => {
|
|
58
|
-
const frame = setup.captureCharFrame();
|
|
59
|
-
|
|
60
|
-
return {
|
|
61
|
-
tabs: frame.includes("Review Tree Agent"),
|
|
62
|
-
submit: frame.includes("Submit review"),
|
|
63
|
-
strip: /\s0\s*\n/.test(frame) && frame.includes("<"),
|
|
64
|
-
};
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
// Assert: expanded
|
|
68
|
-
expect(railSignature()).toEqual({ tabs: true, submit: true, strip: false });
|
|
69
|
-
|
|
70
|
-
// Act
|
|
71
|
-
await pressKey(setup, "r", { ctrl: true });
|
|
72
|
-
|
|
73
|
-
// Assert: compact drops the tabs and the button, keeps the strip
|
|
74
|
-
await waitForTextGone(setup, "Submit review");
|
|
75
|
-
expect(railSignature()).toEqual({ tabs: false, submit: false, strip: true });
|
|
76
|
-
|
|
77
|
-
// Act
|
|
78
|
-
await pressKey(setup, "r", { ctrl: true });
|
|
79
|
-
await settle(setup);
|
|
80
|
-
|
|
81
|
-
// Assert: hidden draws no rail at all
|
|
82
|
-
expect(railSignature()).toEqual({ tabs: false, submit: false, strip: false });
|
|
83
|
-
|
|
84
|
-
// Act
|
|
85
|
-
await pressKey(setup, "r", { ctrl: true });
|
|
86
|
-
|
|
87
|
-
// Assert
|
|
88
|
-
await waitForText(setup, "Submit review"); // back to expanded
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test("the collapse mode persists to the user config", async () => {
|
|
92
|
-
// Arrange
|
|
93
|
-
const setup = await renderApp();
|
|
94
|
-
|
|
95
|
-
// Act
|
|
96
|
-
await pressKey(setup, "r", { ctrl: true }); // -> compact
|
|
97
|
-
|
|
98
|
-
// Assert
|
|
99
|
-
expect(loadConfig({ userConfigPath: configPath }).ui.reviewState).toBe("compact");
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test("option+w widens the rail and persists the new width", async () => {
|
|
103
|
-
// Arrange
|
|
104
|
-
const setup = await renderApp();
|
|
105
|
-
const startWidth = loadConfig({ userConfigPath: configPath }).ui.reviewWidth;
|
|
106
|
-
|
|
107
|
-
// Act
|
|
108
|
-
await pressKey(setup, "w", { meta: true });
|
|
109
|
-
|
|
110
|
-
// Assert
|
|
111
|
-
const widened = loadConfig({ userConfigPath: configPath }).ui.reviewWidth;
|
|
112
|
-
|
|
113
|
-
expect(widened).toBeGreaterThan(startWidth);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test("submitting from a hidden panel force-opens the rail so the confirm card is reachable", async () => {
|
|
117
|
-
// Arrange
|
|
118
|
-
const setup = await renderApp();
|
|
119
|
-
|
|
120
|
-
await pressKey(setup, "r", { ctrl: true }); // -> compact
|
|
121
|
-
await pressKey(setup, "r", { ctrl: true }); // -> hidden
|
|
122
|
-
|
|
123
|
-
// Assert
|
|
124
|
-
await waitForTextGone(setup, "Submit review"); // rail is gone
|
|
125
|
-
expect(setup.captureCharFrame()).not.toContain("Submit review");
|
|
126
|
-
|
|
127
|
-
// Act
|
|
128
|
-
await pressKey(setup, "RETURN", { meta: true }); // submit while hidden must not trap in an invisible modal
|
|
129
|
-
|
|
130
|
-
// Assert
|
|
131
|
-
await waitForText(setup, "submit review");
|
|
132
|
-
const frame = setup.captureCharFrame();
|
|
133
|
-
|
|
134
|
-
expect(frame).toContain("submit review"); // the confirm card is visible again
|
|
135
|
-
expect(frame).toContain("[Approve]");
|
|
136
|
-
|
|
137
|
-
// the reveal is live-only: the saved panel preference stays as the user left it
|
|
138
|
-
expect(loadConfig({ userConfigPath: configPath }).ui.reviewState).toBe("hidden");
|
|
139
|
-
});
|
|
140
|
-
});
|