@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
@@ -0,0 +1,99 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { allFolderIds, flattenTree, statusMeta, type TreeNode } from "./tree-model";
3
+
4
+ const TREE: TreeNode[] = [
5
+ {
6
+ id: "src",
7
+ label: "src",
8
+ children: [
9
+ { id: "src/a.ts", label: "a.ts", status: "modified" },
10
+ {
11
+ id: "src/nested",
12
+ label: "nested",
13
+ children: [
14
+ {
15
+ id: "src/nested/only",
16
+ label: "only",
17
+ children: [{ id: "deep", label: "deep.ts", status: "added" }],
18
+ },
19
+ ],
20
+ },
21
+ ],
22
+ },
23
+ { id: "readme", label: "README.md" },
24
+ ];
25
+
26
+ describe("flattenTree", () => {
27
+ test("a collapsed folder shows only itself; expanding it reveals its children", () => {
28
+ // Act
29
+ const collapsed = flattenTree(TREE, { expandedIds: new Set() });
30
+ const rows = flattenTree(TREE, { expandedIds: new Set(["src"]) });
31
+
32
+ // Assert
33
+ expect(collapsed.map((row) => row.label)).toEqual(["src", "README.md"]);
34
+ expect(rows.map((row) => [row.label, row.depth, row.isFolder])).toEqual([
35
+ ["src", 0, true],
36
+ ["a.ts", 1, false],
37
+ ["nested", 1, true],
38
+ ["README.md", 0, false],
39
+ ]);
40
+ expect(rows.find((row) => row.label === "src")!.expanded).toBe(true);
41
+ expect(rows.find((row) => row.label === "a.ts")!.status).toBe("modified");
42
+ });
43
+
44
+ test("flattenEmptyDirectories collapses a single-subfolder chain into one row", () => {
45
+ // Act - src open but the chain itself collapsed (none of its ids expanded)
46
+ const collapsed = flattenTree(TREE, {
47
+ expandedIds: new Set(["src"]),
48
+ flattenEmptyDirectories: true,
49
+ });
50
+ // Act - expanding via any id along the chain opens it
51
+ const expanded = flattenTree(TREE, {
52
+ expandedIds: new Set(["src", "src/nested/only"]),
53
+ flattenEmptyDirectories: true,
54
+ });
55
+
56
+ // Assert
57
+ expect(collapsed.map((row) => row.label)).toEqual(["src", "a.ts", "nested/only", "README.md"]);
58
+ expect(expanded.map((row) => row.label)).toEqual([
59
+ "src",
60
+ "a.ts",
61
+ "nested/only",
62
+ "deep.ts",
63
+ "README.md",
64
+ ]);
65
+ });
66
+
67
+ test("without the flag the chain stays one folder per row", () => {
68
+ // Act
69
+ const rows = flattenTree(TREE, {
70
+ expandedIds: new Set(["src", "src/nested", "src/nested/only"]),
71
+ });
72
+
73
+ // Assert
74
+ expect(rows.map((row) => [row.label, row.depth])).toEqual([
75
+ ["src", 0],
76
+ ["a.ts", 1],
77
+ ["nested", 1],
78
+ ["only", 2],
79
+ ["deep.ts", 3],
80
+ ["README.md", 0],
81
+ ]);
82
+ });
83
+ });
84
+
85
+ describe("allFolderIds", () => {
86
+ test("collects every folder, not the leaves", () => {
87
+ // Assert
88
+ expect([...allFolderIds(TREE)].sort()).toEqual(["src", "src/nested", "src/nested/only"]);
89
+ });
90
+ });
91
+
92
+ describe("statusMeta", () => {
93
+ test("maps each status to a letter and a tone", () => {
94
+ // Assert
95
+ expect(statusMeta("added")).toEqual({ letter: "A", tone: "green" });
96
+ expect(statusMeta("deleted")).toEqual({ letter: "D", tone: "red" });
97
+ expect(statusMeta("ignored")).toEqual({ letter: "I", tone: "muted" });
98
+ });
99
+ });
@@ -0,0 +1,137 @@
1
+ // Headless tree model mirrored from @pierre/trees (DOM-only): share the model, render our own.
2
+
3
+ export type GitStatus = "added" | "modified" | "deleted" | "renamed" | "untracked" | "ignored";
4
+
5
+ export interface TreeNode {
6
+ id: string;
7
+ label: string;
8
+ children?: TreeNode[];
9
+ status?: GitStatus;
10
+ badge?: string;
11
+ icon?: string;
12
+ }
13
+
14
+ export interface FlattenOptions {
15
+ expandedIds: ReadonlySet<string>;
16
+ flattenEmptyDirectories?: boolean;
17
+ }
18
+
19
+ export interface VisibleRow {
20
+ id: string;
21
+ label: string;
22
+ depth: number;
23
+ isFolder: boolean;
24
+ expanded: boolean;
25
+ status?: GitStatus;
26
+ badge?: string;
27
+ icon?: string;
28
+ }
29
+
30
+ function isFolder(node: TreeNode): boolean {
31
+ return node.children !== undefined;
32
+ }
33
+
34
+ interface CollapsedChain {
35
+ label: string;
36
+ tail: TreeNode;
37
+ chainIds: string[];
38
+ }
39
+
40
+ // flattenEmptyDirectories: fold a chain of single-subfolder folders into one row.
41
+ function collapseChain(node: TreeNode): CollapsedChain {
42
+ let tail = node;
43
+ const labels = [node.label];
44
+ const chainIds = [node.id];
45
+
46
+ while (tail.children?.length === 1 && isFolder(tail.children[0]!)) {
47
+ tail = tail.children[0]!;
48
+ labels.push(tail.label);
49
+ chainIds.push(tail.id);
50
+ }
51
+
52
+ return { label: labels.join("/"), tail, chainIds };
53
+ }
54
+
55
+ export function flattenTree(nodes: readonly TreeNode[], options: FlattenOptions): VisibleRow[] {
56
+ const rows: VisibleRow[] = [];
57
+ const walk = (list: readonly TreeNode[], depth: number): void => {
58
+ for (const node of list) {
59
+ if (!isFolder(node)) {
60
+ const leaf: VisibleRow = {
61
+ id: node.id,
62
+ label: node.label,
63
+ depth,
64
+ isFolder: false,
65
+ expanded: false,
66
+ };
67
+
68
+ if (node.status !== undefined) leaf.status = node.status;
69
+ if (node.badge !== undefined) leaf.badge = node.badge;
70
+ if (node.icon !== undefined) leaf.icon = node.icon;
71
+ rows.push(leaf);
72
+ continue;
73
+ }
74
+ const collapsed = options.flattenEmptyDirectories
75
+ ? collapseChain(node)
76
+ : { label: node.label, tail: node, chainIds: [node.id] };
77
+ // open when any id along a collapsed chain is expanded
78
+ const expanded = collapsed.chainIds.some((id) => options.expandedIds.has(id));
79
+ const row: VisibleRow = {
80
+ id: node.id,
81
+ label: collapsed.label,
82
+ depth,
83
+ isFolder: true,
84
+ expanded,
85
+ };
86
+
87
+ if (node.badge !== undefined) row.badge = node.badge;
88
+ if (node.icon !== undefined) row.icon = node.icon;
89
+ rows.push(row);
90
+ if (expanded) walk(collapsed.tail.children ?? [], depth + 1);
91
+ }
92
+ };
93
+
94
+ walk(nodes, 0);
95
+
96
+ return rows;
97
+ }
98
+
99
+ export function allFolderIds(nodes: readonly TreeNode[]): Set<string> {
100
+ const ids = new Set<string>();
101
+ const walk = (list: readonly TreeNode[]): void => {
102
+ for (const node of list) {
103
+ if (isFolder(node)) {
104
+ ids.add(node.id);
105
+ walk(node.children ?? []);
106
+ }
107
+ }
108
+ };
109
+
110
+ walk(nodes);
111
+
112
+ return ids;
113
+ }
114
+
115
+ export type TreeTone = "green" | "blue" | "red" | "muted";
116
+
117
+ export interface StatusMeta {
118
+ letter: string;
119
+ tone: TreeTone;
120
+ }
121
+
122
+ export function statusMeta(status: GitStatus): StatusMeta {
123
+ switch (status) {
124
+ case "added":
125
+ return { letter: "A", tone: "green" };
126
+ case "modified":
127
+ return { letter: "M", tone: "blue" };
128
+ case "deleted":
129
+ return { letter: "D", tone: "red" };
130
+ case "renamed":
131
+ return { letter: "R", tone: "blue" };
132
+ case "untracked":
133
+ return { letter: "U", tone: "green" };
134
+ case "ignored":
135
+ return { letter: "I", tone: "muted" };
136
+ }
137
+ }
@@ -4,7 +4,7 @@ import { DEFAULT_QUICK_ACTIONS } from "../config";
4
4
  import type { Story, StoryMeta } from "./story";
5
5
  import { QuickActionsEditor } from "./quick-actions-editor";
6
6
 
7
- export const meta: StoryMeta = { title: "QuickActionsEditor" };
7
+ export const meta: StoryMeta = { title: "Chrome/QuickActionsEditor" };
8
8
 
9
9
  /** The editor renders inside the settings body column. */
10
10
  function BodyFrame({ children }: { children: React.ReactNode }): React.ReactNode {
@@ -0,0 +1,114 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ReviewSession, WorkspaceKey } from "@cueloop/schema";
3
+ import { groupInbox, projectName } from "./session-tree";
4
+
5
+ function session(id: string, title: string, rootCommit?: string, remote?: string): ReviewSession {
6
+ const workspace: WorkspaceKey = { repoRoot: `/home/dev/${id}-checkout`, branch: "main" };
7
+ if (rootCommit !== undefined) workspace.rootCommit = rootCommit;
8
+ if (remote !== undefined) workspace.remote = remote;
9
+
10
+ return {
11
+ schemaVersion: "1",
12
+ id,
13
+ workspace,
14
+ artifact: { type: "plan", content: `# ${title}\n`, meta: { title } },
15
+ revisions: [],
16
+ annotations: [],
17
+ verdict: null,
18
+ status: "pending",
19
+ createdAt: "2026-01-01T00:00:00.000Z",
20
+ };
21
+ }
22
+
23
+ describe("projectName", () => {
24
+ test("prefers the remote basename and strips the .git suffix", () => {
25
+ // Arrange
26
+ const workspace: WorkspaceKey = {
27
+ repoRoot: "/tmp/whatever",
28
+ branch: "main",
29
+ remote: "git@github.com:acme/widget.git",
30
+ };
31
+
32
+ // Act
33
+ const name = projectName(workspace);
34
+
35
+ // Assert
36
+ expect(name).toBe("widget");
37
+ });
38
+
39
+ test("falls back to the working-tree folder name when there is no remote", () => {
40
+ // Arrange
41
+ const workspace: WorkspaceKey = { repoRoot: "/home/dev/cueloop", branch: "main" };
42
+
43
+ // Act
44
+ const name = projectName(workspace);
45
+
46
+ // Assert
47
+ expect(name).toBe("cueloop");
48
+ });
49
+ });
50
+
51
+ describe("groupInbox", () => {
52
+ test("splits repo-bound threads into Projects and the rest into Threads", () => {
53
+ // Arrange
54
+ const sessions = [
55
+ session("a", "Read the repo", "root-1", "git@github.com:acme/widget.git"),
56
+ session("b", "A loose idea"),
57
+ ];
58
+
59
+ // Act
60
+ const { rows, ordered } = groupInbox(sessions);
61
+
62
+ // Assert
63
+ const labels = rows.map((row) => `${row.kind}:${"label" in row ? row.label : row.id}`);
64
+ expect(labels).toEqual([
65
+ "section:Projects",
66
+ "project:widget",
67
+ "thread:a",
68
+ "section:Threads",
69
+ "thread:b",
70
+ ]);
71
+ expect(ordered.map((s) => s.id)).toEqual(["a", "b"]);
72
+ });
73
+
74
+ test("collapses threads sharing a root commit into one project", () => {
75
+ // Arrange
76
+ const sessions = [
77
+ session("a", "First", "root-1", "git@github.com:acme/widget.git"),
78
+ session("b", "Second", "root-1", "git@github.com:acme/widget.git"),
79
+ ];
80
+
81
+ // Act
82
+ const { rows, ordered } = groupInbox(sessions);
83
+
84
+ // Assert
85
+ const projectRows = rows.filter((row) => row.kind === "project");
86
+ expect(projectRows).toHaveLength(1);
87
+ expect(ordered).toHaveLength(2);
88
+ });
89
+
90
+ test("selectionIndex on each thread matches its position in ordered", () => {
91
+ // Arrange
92
+ const sessions = [session("a", "One", "root-1"), session("b", "Two")];
93
+
94
+ // Act
95
+ const { rows, ordered } = groupInbox(sessions);
96
+
97
+ // Assert
98
+ for (const row of rows) {
99
+ if (row.kind === "thread") expect(ordered[row.selectionIndex]!.id).toBe(row.id);
100
+ }
101
+ });
102
+
103
+ test("omits the Projects section when nothing is repo-bound", () => {
104
+ // Arrange
105
+ const sessions = [session("a", "Loose one"), session("b", "Loose two")];
106
+
107
+ // Act
108
+ const { rows } = groupInbox(sessions);
109
+
110
+ // Assert
111
+ expect(rows.some((row) => row.kind === "section" && row.label === "Projects")).toBe(false);
112
+ expect(rows.some((row) => row.kind === "section" && row.label === "Threads")).toBe(true);
113
+ });
114
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Groups the pending inbox into Projects and Threads for the sidebar. Threads
3
+ * whose workspace carries a rootCommit gather under their project (keyed by that
4
+ * commit, so moving or re-cloning the repo never forks it); the rest are
5
+ * standalone Threads. The flat `ordered` sequence is what the inbox cursor
6
+ * walks, so the keyboard grammar keeps a single index across the two groups.
7
+ */
8
+
9
+ import type { ReviewSession, WorkspaceKey } from "@cueloop/schema";
10
+
11
+ /** A repo's display name: the remote basename when present, else the working-tree folder name. */
12
+ export function projectName(workspace: WorkspaceKey): string {
13
+ const { remote, repoRoot } = workspace;
14
+
15
+ if (remote !== undefined) {
16
+ const cleaned = remote.replace(/\.git$/, "").replace(/\/+$/, "");
17
+ const base = cleaned
18
+ .split(/[/:]/)
19
+ .filter((part) => part.length > 0)
20
+ .at(-1);
21
+ if (base !== undefined) return base;
22
+ }
23
+
24
+ const dir = repoRoot
25
+ .replace(/\/+$/, "")
26
+ .split("/")
27
+ .filter((part) => part.length > 0)
28
+ .at(-1);
29
+
30
+ return dir ?? repoRoot;
31
+ }
32
+
33
+ export type InboxRow =
34
+ | { kind: "section"; id: string; label: string }
35
+ | { kind: "project"; id: string; label: string }
36
+ | { kind: "thread"; id: string; session: ReviewSession; selectionIndex: number };
37
+
38
+ export interface GroupedInbox {
39
+ rows: InboxRow[];
40
+ /** Threads in display order; the inbox cursor indexes this. */
41
+ ordered: ReviewSession[];
42
+ }
43
+
44
+ function threadTitle(session: ReviewSession): string {
45
+ return session.artifact.meta.title ?? session.id;
46
+ }
47
+
48
+ interface ProjectGroup {
49
+ name: string;
50
+ sessions: ReviewSession[];
51
+ }
52
+
53
+ /** Group pending sessions into Projects (by root commit) then standalone Threads. */
54
+ export function groupInbox(sessions: readonly ReviewSession[]): GroupedInbox {
55
+ const projects = new Map<string, ProjectGroup>();
56
+ const standalone: ReviewSession[] = [];
57
+
58
+ for (const session of sessions) {
59
+ const key = session.workspace.rootCommit;
60
+ if (key === undefined) {
61
+ standalone.push(session);
62
+ continue;
63
+ }
64
+
65
+ const existing = projects.get(key);
66
+ if (existing !== undefined) existing.sessions.push(session);
67
+ else projects.set(key, { name: projectName(session.workspace), sessions: [session] });
68
+ }
69
+
70
+ const rows: InboxRow[] = [];
71
+ const ordered: ReviewSession[] = [];
72
+ const pushThread = (session: ReviewSession): void => {
73
+ rows.push({ kind: "thread", id: session.id, session, selectionIndex: ordered.length });
74
+ ordered.push(session);
75
+ };
76
+
77
+ const projectEntries = [...projects.entries()].sort((a, b) => a[1].name.localeCompare(b[1].name));
78
+ if (projectEntries.length > 0) {
79
+ rows.push({ kind: "section", id: "section:projects", label: "Projects" });
80
+ for (const [key, project] of projectEntries) {
81
+ rows.push({ kind: "project", id: `project:${key}`, label: project.name });
82
+ for (const session of project.sessions) pushThread(session);
83
+ }
84
+ }
85
+
86
+ if (standalone.length > 0) {
87
+ rows.push({ kind: "section", id: "section:threads", label: "Threads" });
88
+ for (const session of standalone) pushThread(session);
89
+ }
90
+
91
+ return { rows, ordered };
92
+ }
93
+
94
+ export { threadTitle };