@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.
Files changed (78) hide show
  1. package/package.json +5 -5
  2. package/src/App.diff.test.tsx +8 -73
  3. package/src/App.plan-review.test.tsx +4 -185
  4. package/src/App.prototype.test.tsx +1 -8
  5. package/src/App.readonly.test.tsx +6 -28
  6. package/src/App.submit-confirm.test.tsx +19 -72
  7. package/src/App.test.tsx +70 -24
  8. package/src/App.tsx +177 -254
  9. package/src/App.walk.test.tsx +1 -1
  10. package/src/app-screens.tsx +40 -51
  11. package/src/app-view-model.ts +2 -4
  12. package/src/components/AnnotationCard.stories.tsx +1 -1
  13. package/src/components/AppShell.stories.tsx +84 -0
  14. package/src/components/AppShell.tsx +127 -0
  15. package/src/components/Breadcrumb.stories.tsx +1 -1
  16. package/src/components/ChangesColumn.stories.tsx +39 -0
  17. package/src/components/ChangesColumn.tsx +147 -0
  18. package/src/components/CodeBlock.stories.tsx +1 -1
  19. package/src/components/CompletionOverlay.stories.tsx +1 -1
  20. package/src/components/ConfirmCard.stories.tsx +1 -1
  21. package/src/components/ConfirmCard.tsx +3 -4
  22. package/src/components/ConfirmDialog.stories.tsx +1 -1
  23. package/src/components/DiffSheet.stories.tsx +1 -1
  24. package/src/components/InboxList.stories.tsx +23 -8
  25. package/src/components/InboxList.tsx +45 -19
  26. package/src/components/KeybindsDialog.stories.tsx +1 -1
  27. package/src/components/MarkerPopover.stories.tsx +1 -1
  28. package/src/components/MenuBar.stories.tsx +3 -12
  29. package/src/components/MenuBar.tsx +25 -56
  30. package/src/components/PromptDialog.stories.tsx +1 -1
  31. package/src/components/PrototypeSheet.stories.tsx +1 -1
  32. package/src/components/ReviewPanel.stories.tsx +1 -1
  33. package/src/components/ReviewRail.stories.tsx +1 -1
  34. package/src/components/SettingsDialog.stories.tsx +7 -1
  35. package/src/components/SettingsDialog.tsx +112 -66
  36. package/src/components/SettingsRows.stories.tsx +1 -1
  37. package/src/components/ShellHeader.stories.tsx +50 -0
  38. package/src/components/ShellHeader.tsx +137 -0
  39. package/src/components/ThreadFooter.stories.tsx +30 -0
  40. package/src/components/ThreadFooter.tsx +49 -0
  41. package/src/components/ThreadView.slash.test.tsx +113 -0
  42. package/src/components/ThreadView.stories.tsx +1 -1
  43. package/src/components/ThreadView.tsx +99 -11
  44. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  45. package/src/components/ThreadsSidebar.tsx +57 -0
  46. package/src/components/Toast.stories.tsx +1 -1
  47. package/src/components/TreePane.stories.tsx +1 -1
  48. package/src/components/WalkWizard.stories.tsx +1 -1
  49. package/src/components/__snapshots__/stories.test.tsx.snap +348 -138
  50. package/src/components/agent-launcher.stories.tsx +1 -1
  51. package/src/components/file-tree.test.ts +45 -0
  52. package/src/components/file-tree.ts +57 -0
  53. package/src/components/pages/Workspace.stories.tsx +181 -0
  54. package/src/components/primitives/Button.stories.tsx +1 -1
  55. package/src/components/primitives/Card.stories.tsx +1 -1
  56. package/src/components/primitives/Dialog.stories.tsx +1 -1
  57. package/src/components/primitives/IconButton.stories.tsx +36 -0
  58. package/src/components/primitives/IconButton.tsx +43 -0
  59. package/src/components/primitives/StatusBar.stories.tsx +1 -1
  60. package/src/components/primitives/Tabs.stories.tsx +1 -1
  61. package/src/components/primitives/Toolbar.stories.tsx +1 -1
  62. package/src/components/primitives/Tree.stories.tsx +124 -0
  63. package/src/components/primitives/Tree.tsx +87 -0
  64. package/src/components/primitives/icons.ts +38 -0
  65. package/src/components/primitives/tree-model.test.ts +99 -0
  66. package/src/components/primitives/tree-model.ts +137 -0
  67. package/src/components/quick-actions-editor.stories.tsx +1 -1
  68. package/src/components/session-tree.test.ts +114 -0
  69. package/src/components/session-tree.ts +94 -0
  70. package/src/components/stories-app.tsx +142 -82
  71. package/src/components/story-tree.test.ts +100 -0
  72. package/src/components/story.ts +55 -0
  73. package/src/components/theme-context.stories.tsx +1 -1
  74. package/src/session-controller.ts +9 -4
  75. package/src/theme.ts +4 -4
  76. package/src/use-settings-dialog.tsx +9 -0
  77. package/src/App.review-panel.test.tsx +0 -140
  78. package/src/App.tree.test.tsx +0 -224
@@ -1,109 +1,169 @@
1
1
  /**
2
- * The interactive stories catalog (`bun run stories`): a left list of
3
- * component modules (j/k), h/l cycling through the module's stories, and the
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 { FRAME_BORDER_STYLE } from "./primitives/frame";
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
- function StoriesApp({
17
- stories,
18
- onExit,
19
- }: {
20
+ interface StoriesAppProps {
20
21
  stories: LoadedStory[];
21
22
  onExit: () => void;
22
- }): React.ReactNode {
23
- const moduleTitles = useMemo(
24
- () => [...new Set(stories.map((story) => story.moduleTitle))],
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 [moduleIndex, setModuleIndex] = useState(0);
28
- const [storyIndex, setStoryIndex] = useState(0);
29
- const moduleTitle = moduleTitles[moduleIndex]!;
30
- const moduleStories = stories.filter((story) => story.moduleTitle === moduleTitle);
31
- const current = moduleStories[Math.min(storyIndex, moduleStories.length - 1)]!;
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
- setModuleIndex((index) => Math.min(moduleTitles.length - 1, index + 1));
37
- setStoryIndex(0);
38
- } else if (key.name === "k" || key.name === "up") {
39
- setModuleIndex((index) => Math.max(0, index - 1));
40
- setStoryIndex(0);
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
- setStoryIndex((index) => (index - 1 + moduleStories.length) % moduleStories.length);
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
- <box
51
- style={{
52
- flexDirection: "column",
53
- width: "100%",
54
- height: "100%",
55
- backgroundColor: DARK.background,
56
- }}
57
- >
58
- <box style={{ height: 1, backgroundColor: DARK.panel, paddingLeft: 1 }}>
59
- <text fg={DARK.text}>
60
- <span fg={DARK.accent}>cueloop stories</span>
61
- <span fg={DARK.textDim}>
62
- {" "}
63
- · {moduleTitle} / {current.storyName}
64
- </span>
65
- </text>
66
- </box>
67
- <box style={{ flexDirection: "row", flexGrow: 1 }}>
68
- <box
69
- style={{
70
- width: 28,
71
- backgroundColor: DARK.panel,
72
- flexDirection: "column",
73
- paddingLeft: 1,
74
- paddingTop: 1,
75
- }}
76
- >
77
- {moduleTitles.map((title, index) => (
78
- <text key={title} fg={index === moduleIndex ? DARK.accent : DARK.textMuted}>
79
- {index === moduleIndex ? "▸ " : " "}
80
- {title}
81
- </text>
82
- ))}
83
- </box>
84
- <box
85
- style={{
86
- flexGrow: 1,
87
- flexDirection: "column",
88
- border: true,
89
- borderStyle: FRAME_BORDER_STYLE,
90
- borderColor: DARK.border,
91
- }}
92
- title={` ${current.storyName} (${moduleStories.indexOf(current) + 1}/${moduleStories.length}) `}
93
- >
94
- {/* remount per story so component-local state never leaks across */}
95
- <box
96
- key={`${moduleTitle}/${current.storyName}`}
97
- style={{ flexGrow: 1, flexDirection: "column" }}
98
- >
99
- {current.story.render()}
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
- </box>
103
- <box style={{ height: 1, backgroundColor: DARK.panel, paddingLeft: 1 }}>
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
+ });
@@ -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: "theme-context" };
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
- if (!this.options.sessionId) void this.refreshInbox();
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: await client.sessionList({ status: "pending" }) });
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
- this.update({ status: message });
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: "#f5a3a3",
58
- accentInk: "#2a1416",
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: "#b5495b",
84
- accentInk: "#ffffff",
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
- });