@cueloop/client 0.1.0-alpha.64 → 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 (109) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.diff.test.tsx +8 -73
  4. package/src/App.export.test.tsx +2 -2
  5. package/src/App.plan-review.test.tsx +60 -412
  6. package/src/App.prototype.test.tsx +1 -8
  7. package/src/App.readonly.test.tsx +85 -62
  8. package/src/App.rendering.test.tsx +5 -6
  9. package/src/App.submit-confirm.test.tsx +32 -70
  10. package/src/App.test.tsx +136 -66
  11. package/src/App.toast.test.tsx +17 -8
  12. package/src/App.tsx +243 -284
  13. package/src/App.walk.test.tsx +1 -1
  14. package/src/app-screens.tsx +67 -52
  15. package/src/app-view-model.ts +6 -100
  16. package/src/components/AnnotationCard.stories.tsx +1 -1
  17. package/src/components/AnnotationCard.tsx +2 -2
  18. package/src/components/AppShell.stories.tsx +84 -0
  19. package/src/components/AppShell.tsx +127 -0
  20. package/src/components/Breadcrumb.stories.tsx +1 -1
  21. package/src/components/ChangesColumn.stories.tsx +39 -0
  22. package/src/components/ChangesColumn.tsx +147 -0
  23. package/src/components/CodeBlock.stories.tsx +1 -1
  24. package/src/components/CompletionOverlay.stories.tsx +1 -1
  25. package/src/components/ConfirmCard.stories.tsx +1 -1
  26. package/src/components/ConfirmCard.tsx +3 -4
  27. package/src/components/ConfirmDialog.stories.tsx +1 -1
  28. package/src/components/DiffSheet.stories.tsx +1 -1
  29. package/src/components/InboxList.stories.tsx +23 -8
  30. package/src/components/InboxList.tsx +45 -19
  31. package/src/components/KeybindsDialog.stories.tsx +1 -1
  32. package/src/components/KeybindsDialog.tsx +61 -17
  33. package/src/components/MarkerPopover.stories.tsx +1 -1
  34. package/src/components/MenuBar.stories.tsx +3 -12
  35. package/src/components/MenuBar.tsx +25 -56
  36. package/src/components/PromptDialog.stories.tsx +1 -1
  37. package/src/components/PrototypeSheet.stories.tsx +1 -1
  38. package/src/components/ReviewPanel.stories.tsx +15 -1
  39. package/src/components/ReviewRail.stories.tsx +15 -1
  40. package/src/components/ReviewRail.tsx +13 -6
  41. package/src/components/SettingsDialog.stories.tsx +7 -1
  42. package/src/components/SettingsDialog.tsx +112 -66
  43. package/src/components/SettingsRows.stories.tsx +1 -1
  44. package/src/components/ShellHeader.stories.tsx +50 -0
  45. package/src/components/ShellHeader.tsx +137 -0
  46. package/src/components/ThreadFooter.stories.tsx +30 -0
  47. package/src/components/ThreadFooter.tsx +49 -0
  48. package/src/components/ThreadView.selection.test.tsx +812 -0
  49. package/src/components/ThreadView.slash.test.tsx +113 -0
  50. package/src/components/ThreadView.stories.tsx +26 -0
  51. package/src/components/ThreadView.tsx +1708 -0
  52. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  53. package/src/components/ThreadsSidebar.tsx +57 -0
  54. package/src/components/Toast.stories.tsx +1 -1
  55. package/src/components/TreePane.stories.tsx +106 -0
  56. package/src/components/TreePane.tsx +156 -0
  57. package/src/components/WalkWizard.stories.tsx +1 -1
  58. package/src/components/__snapshots__/stories.test.tsx.snap +463 -305
  59. package/src/components/agent-launcher.stories.tsx +1 -1
  60. package/src/components/file-tree.test.ts +45 -0
  61. package/src/components/file-tree.ts +57 -0
  62. package/src/components/pages/Workspace.stories.tsx +181 -0
  63. package/src/components/primitives/Button.stories.tsx +1 -1
  64. package/src/components/primitives/Card.stories.tsx +1 -1
  65. package/src/components/primitives/Dialog.stories.tsx +1 -1
  66. package/src/components/primitives/IconButton.stories.tsx +36 -0
  67. package/src/components/primitives/IconButton.tsx +43 -0
  68. package/src/components/primitives/StatusBar.stories.tsx +1 -1
  69. package/src/components/primitives/Tabs.stories.tsx +1 -1
  70. package/src/components/primitives/Toolbar.stories.tsx +1 -1
  71. package/src/components/primitives/Tree.stories.tsx +124 -0
  72. package/src/components/primitives/Tree.tsx +87 -0
  73. package/src/components/primitives/icons.ts +38 -0
  74. package/src/components/primitives/tree-model.test.ts +99 -0
  75. package/src/components/primitives/tree-model.ts +137 -0
  76. package/src/components/quick-actions-editor.stories.tsx +1 -1
  77. package/src/components/session-tree.test.ts +114 -0
  78. package/src/components/session-tree.ts +94 -0
  79. package/src/components/stories-app.tsx +142 -82
  80. package/src/components/story-tree.test.ts +100 -0
  81. package/src/components/story.ts +55 -0
  82. package/src/components/theme-context.stories.tsx +1 -1
  83. package/src/diff-hunk-curate.ts +4 -104
  84. package/src/index.ts +1 -0
  85. package/src/intent-dispatch.test.ts +136 -3
  86. package/src/intent-dispatch.ts +92 -24
  87. package/src/key-bindings.test.ts +1 -1
  88. package/src/keymap.test.ts +4 -4
  89. package/src/keymap.ts +14 -7
  90. package/src/session-controller.curate.test.ts +89 -2
  91. package/src/session-controller.share.test.ts +195 -55
  92. package/src/session-controller.tree.test.ts +228 -0
  93. package/src/session-controller.ts +331 -72
  94. package/src/share.ts +114 -16
  95. package/src/test-support.test.ts +193 -1
  96. package/src/test-support.ts +64 -0
  97. package/src/theme.ts +4 -4
  98. package/src/thread-chords.test.ts +154 -0
  99. package/src/thread-chords.ts +144 -0
  100. package/src/thread-selection.test.ts +210 -0
  101. package/src/thread-selection.ts +243 -0
  102. package/src/tree-view.test.ts +128 -0
  103. package/src/tree-view.ts +148 -0
  104. package/src/use-settings-dialog.tsx +9 -0
  105. package/src/view-plan.ts +29 -12
  106. package/src/App.review-panel.test.tsx +0 -143
  107. package/src/components/PlanSheet.stories.tsx +0 -113
  108. package/src/components/PlanSheet.tsx +0 -419
  109. package/src/components/plan-sheet-run-style.ts +0 -88
@@ -0,0 +1,128 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ appendEntry,
4
+ createBranch,
5
+ historyFromLinear,
6
+ labelTip,
7
+ navigateTo,
8
+ switchBranch,
9
+ tipOf,
10
+ type SessionHistory,
11
+ } from "@cueloop/schema";
12
+ import { entryTarget, treeRows } from "./tree-view";
13
+
14
+ const AT = "2026-09-01T10:00:00.000Z";
15
+ const LATER = "2026-09-01T11:00:00.000Z";
16
+
17
+ /** rev1 -> comment a1 on main; a branch "alt" off a1 with its own comment; main continues with rev2. */
18
+ function forked(): SessionHistory {
19
+ let history = historyFromLinear({
20
+ id: "ses_1",
21
+ revisions: [{ revision: 1, content: "v1", submittedAt: AT }],
22
+ annotations: [],
23
+ verdict: null,
24
+ createdAt: AT,
25
+ });
26
+
27
+ history = appendEntry(history, { type: "comment", annotationId: "a1", createdAt: AT }).history;
28
+ history = labelTip(history, "start");
29
+ history = createBranch(history, "alt");
30
+ history = appendEntry(history, { type: "comment", annotationId: "b1", createdAt: AT }).history;
31
+ history = switchBranch(history, "main");
32
+ history = appendEntry(history, {
33
+ type: "revision",
34
+ by: "agent",
35
+ content: "v2",
36
+ createdAt: LATER,
37
+ }).history;
38
+
39
+ return history;
40
+ }
41
+
42
+ describe("treeRows", () => {
43
+ test("the current branch is the trunk; other segments indent under the entry they grew from", () => {
44
+ // Act
45
+ const rows = treeRows(forked());
46
+
47
+ // Assert: rev1, a1 (labelled, alt hangs off it), the alt comment indented, then rev2 as main's tip
48
+ expect(rows.map((row) => [row.depth, row.text, row.onPath])).toEqual([
49
+ [0, "revision 1", true],
50
+ [0, "comment", true],
51
+ [1, "comment", false],
52
+ [0, "revision 2", true],
53
+ ]);
54
+ expect(rows[1]!.label).toBe("start");
55
+ expect(rows[2]!.tips).toEqual(["alt"]);
56
+ expect(rows[3]!.tips).toEqual(["main"]);
57
+ expect(rows[3]!.isCurrentTip).toBe(true);
58
+ });
59
+
60
+ test("on the other branch the trunk follows it and main's continuation indents", () => {
61
+ // Act
62
+ const rows = treeRows(switchBranch(forked(), "alt"));
63
+
64
+ // Assert
65
+ expect(rows.map((row) => [row.depth, row.text, row.onPath])).toEqual([
66
+ [0, "revision 1", true],
67
+ [0, "comment", true],
68
+ [1, "revision 2", false],
69
+ [0, "comment", true],
70
+ ]);
71
+ expect(rows[3]!.isCurrentTip).toBe(true);
72
+ });
73
+
74
+ test("a branch summary reads as a return with its text", () => {
75
+ // Arrange
76
+ const history = navigateTo(forked(), forked().entries[0]!.id, { summary: "too early" });
77
+
78
+ // Act
79
+ const rows = treeRows(history);
80
+
81
+ // Assert: the summary hangs under rev1 as the trunk, the abandoned segment indents
82
+ expect(rows.find((row) => row.glyph === "↩")?.text).toBe('"too early"');
83
+ expect(rows.find((row) => row.glyph === "↩")?.isCurrentTip).toBe(true);
84
+ });
85
+ });
86
+
87
+ describe("entryTarget", () => {
88
+ test("the tip is here, another tip is a switch, an ancestor is a navigate on its branch", () => {
89
+ // Arrange
90
+ const history = forked();
91
+ const [rev1, a1, altComment, rev2] = history.entries;
92
+
93
+ // Assert
94
+ expect(entryTarget(history, rev2!.id)).toEqual({ kind: "here" });
95
+ expect(entryTarget(history, altComment!.id)).toEqual({ kind: "switch", branch: "alt" });
96
+ expect(entryTarget(history, a1!.id)).toEqual({
97
+ kind: "navigate",
98
+ entryId: a1!.id,
99
+ branch: "main",
100
+ });
101
+ expect(entryTarget(history, rev1!.id)?.kind).toBe("navigate");
102
+ expect(tipOf(history)).toBe(rev2!.id);
103
+ });
104
+
105
+ test("an entry only another branch reaches is a navigate on that branch", () => {
106
+ // Arrange: alt gets a second comment so its first is no longer a tip
107
+ let history = switchBranch(forked(), "alt");
108
+
109
+ history = appendEntry(history, {
110
+ type: "comment",
111
+ annotationId: "b2",
112
+ createdAt: LATER,
113
+ }).history;
114
+ const altFirst = history.entries.find(
115
+ (entry) => entry.type === "comment" && entry.annotationId === "b1",
116
+ )!;
117
+
118
+ history = switchBranch(history, "main");
119
+
120
+ // Assert
121
+ expect(entryTarget(history, altFirst.id)).toEqual({
122
+ kind: "navigate",
123
+ entryId: altFirst.id,
124
+ branch: "alt",
125
+ });
126
+ expect(entryTarget(history, "e_nope")).toBeNull();
127
+ });
128
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The session tree as rows for the rail's Tree tab. The trunk is the current
3
+ * branch's path (falling back to main), drawn at depth 0; every other segment
4
+ * indents under the entry it grew from. Rows know whether they sit on the
5
+ * active path, which branch tips rest on them, and the checkpoint name they
6
+ * carry, so the pane only paints. Pure: no controller, no daemon.
7
+ */
8
+
9
+ import {
10
+ MAIN_BRANCH,
11
+ pathOf,
12
+ tipOf,
13
+ type SessionEntry,
14
+ type SessionHistory,
15
+ } from "@cueloop/schema";
16
+
17
+ export interface TreeRow {
18
+ entryId: string;
19
+ /** Branch nesting: 0 is the trunk, each segment off it one deeper. */
20
+ depth: number;
21
+ /** One glyph naming the entry kind, for the left column. */
22
+ glyph: string;
23
+ /** What the entry is, in a few words. */
24
+ text: string;
25
+ /** On the current branch's path: painted bright; everything else dim. */
26
+ onPath: boolean;
27
+ /** Branches whose tip rests on this entry. */
28
+ tips: string[];
29
+ /** The current branch's tip: where the next entry lands. */
30
+ isCurrentTip: boolean;
31
+ /** The checkpoint name, when labelled. */
32
+ label?: string;
33
+ }
34
+
35
+ /** What going to an entry means from where the reviewer stands. */
36
+ export type EntryTarget =
37
+ | { kind: "here" }
38
+ | { kind: "switch"; branch: string }
39
+ | { kind: "navigate"; entryId: string; branch: string };
40
+
41
+ /** The one-glyph, few-words reading of an entry a row shows. */
42
+ export interface EntryDescription {
43
+ glyph: string;
44
+ text: string;
45
+ }
46
+
47
+ /** The entry's kind, said briefly; agent revisions count their round along the path. */
48
+ export function describeEntry(entry: SessionEntry, round?: number): EntryDescription {
49
+ switch (entry.type) {
50
+ case "revision":
51
+ return entry.by === "agent"
52
+ ? { glyph: "◉", text: round === undefined ? "revision" : `revision ${round}` }
53
+ : { glyph: "○", text: "edit" };
54
+ case "comment":
55
+ return { glyph: "·", text: "comment" };
56
+ case "comment-removed":
57
+ return { glyph: "·", text: "comment removed" };
58
+ case "verdict":
59
+ return {
60
+ glyph: entry.verdict.kind === "approve" ? "✓" : "✗",
61
+ text: entry.verdict.kind.replace("_", " "),
62
+ };
63
+ case "branch-summary":
64
+ return { glyph: "↩", text: entry.text ? `"${entry.text}"` : "returned" };
65
+ }
66
+ }
67
+
68
+ /** Rows in drawing order: trunk first at each fork, older segments before newer. */
69
+ export function treeRows(history: SessionHistory): TreeRow[] {
70
+ const children = new Map<string | null, SessionEntry[]>();
71
+
72
+ for (const entry of history.entries) {
73
+ const siblings = children.get(entry.parentId) ?? [];
74
+
75
+ siblings.push(entry);
76
+ children.set(entry.parentId, siblings);
77
+ }
78
+ for (const siblings of children.values()) {
79
+ siblings.sort(
80
+ (left, right) =>
81
+ left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id),
82
+ );
83
+ }
84
+ const onPath = new Set(pathOf(history).map((entry) => entry.id));
85
+ const onMain = new Set(pathOf(history, tipOf(history, MAIN_BRANCH)).map((entry) => entry.id));
86
+ const tipsByEntry = new Map<string, string[]>();
87
+
88
+ for (const [branch, tip] of Object.entries(history.tips)) {
89
+ tipsByEntry.set(tip, [...(tipsByEntry.get(tip) ?? []), branch]);
90
+ }
91
+ const currentTip = tipOf(history);
92
+ const rows: TreeRow[] = [];
93
+ // the trunk continues through the child on the active path, else on main, else the oldest
94
+ const trunkChild = (siblings: SessionEntry[]): SessionEntry =>
95
+ siblings.find((entry) => onPath.has(entry.id)) ??
96
+ siblings.find((entry) => onMain.has(entry.id)) ??
97
+ siblings[0]!;
98
+ const walk = (entry: SessionEntry, depth: number, round: number): void => {
99
+ const nextRound = entry.type === "revision" && entry.by === "agent" ? round + 1 : round;
100
+ const described = describeEntry(
101
+ entry,
102
+ entry.type === "revision" && entry.by === "agent" ? nextRound : undefined,
103
+ );
104
+ const label = history.labels[entry.id];
105
+ const row: TreeRow = {
106
+ entryId: entry.id,
107
+ depth,
108
+ glyph: described.glyph,
109
+ text: described.text,
110
+ onPath: onPath.has(entry.id),
111
+ tips: tipsByEntry.get(entry.id) ?? [],
112
+ isCurrentTip: entry.id === currentTip,
113
+ };
114
+
115
+ if (label !== undefined) row.label = label;
116
+ rows.push(row);
117
+ const siblings = children.get(entry.id) ?? [];
118
+
119
+ if (siblings.length === 0) return;
120
+ const trunk = trunkChild(siblings);
121
+
122
+ for (const child of siblings) if (child !== trunk) walk(child, depth + 1, nextRound);
123
+ walk(trunk, depth, nextRound);
124
+ };
125
+
126
+ for (const root of children.get(null) ?? []) walk(root, 0, 0);
127
+
128
+ return rows;
129
+ }
130
+
131
+ /**
132
+ * Going to an entry: nothing when it is the current tip; a switch when it is
133
+ * another branch's tip; otherwise a navigate on the branch whose path holds
134
+ * it - the current branch when it can, else the first branch that reaches it.
135
+ */
136
+ export function entryTarget(history: SessionHistory, entryId: string): EntryTarget | null {
137
+ if (entryId === tipOf(history)) return { kind: "here" };
138
+ const tipBranch = Object.entries(history.tips).find(([, tip]) => tip === entryId);
139
+
140
+ if (tipBranch) return { kind: "switch", branch: tipBranch[0] };
141
+ const holds = (branch: string): boolean =>
142
+ pathOf(history, tipOf(history, branch)).some((entry) => entry.id === entryId);
143
+ const branch = holds(history.branch)
144
+ ? history.branch
145
+ : Object.keys(history.tips).find((candidate) => holds(candidate));
146
+
147
+ return branch === undefined ? null : { kind: "navigate", entryId, branch };
148
+ }
@@ -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
  };
package/src/view-plan.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  type Block,
18
18
  } from "@cueloop/schema";
19
19
  import { wordLevelChanges } from "./diff-intraline";
20
+ import { spanRangeInBlock, type TextSpan } from "./thread-selection";
20
21
 
21
22
  // ── display model ─────────────────────────────
22
23
 
@@ -182,6 +183,8 @@ export interface Mark {
182
183
  end: number;
183
184
  role: RunRole;
184
185
  annotationId?: string;
186
+ /** The whole anchored stretch in display coordinates; one thread per span. */
187
+ span?: TextSpan;
185
188
  }
186
189
 
187
190
  /**
@@ -207,18 +210,32 @@ export function marksByDisplay(
207
210
  const resolved = resolveAnchor(annotation.anchor, workBlocks);
208
211
 
209
212
  if (!resolved) continue;
210
- const entry = workEntries[resolved.blockIndex];
211
-
212
- if (!entry) continue;
213
- const marks = marksByIndex.get(entry.displayIndex) ?? [];
214
-
215
- marks.push({
216
- start: resolved.start,
217
- end: resolved.end,
218
- role: annotation.id === focusedId ? "mark-focus" : "mark-comment",
219
- annotationId: annotation.id,
220
- });
221
- marksByIndex.set(entry.displayIndex, marks);
213
+ const startEntry = workEntries[resolved.blockIndex];
214
+ const endEntry = workEntries[resolved.endBlockIndex];
215
+
216
+ if (!startEntry || !endEntry) continue;
217
+ const span: TextSpan = {
218
+ start: { blockIndex: startEntry.displayIndex, char: resolved.start },
219
+ end: { blockIndex: endEntry.displayIndex, char: resolved.end },
220
+ };
221
+
222
+ // a span paints on every block it covers; the mark carries the whole span
223
+ for (let workIndex = resolved.blockIndex; workIndex <= resolved.endBlockIndex; workIndex++) {
224
+ const entry = workEntries[workIndex]!;
225
+ const range = spanRangeInBlock(span, entry.displayIndex, entry.block.work!.text.length);
226
+
227
+ if (!range) continue;
228
+ const marks = marksByIndex.get(entry.displayIndex) ?? [];
229
+
230
+ marks.push({
231
+ start: range.start,
232
+ end: range.end,
233
+ role: annotation.id === focusedId ? "mark-focus" : "mark-comment",
234
+ annotationId: annotation.id,
235
+ span,
236
+ });
237
+ marksByIndex.set(entry.displayIndex, marks);
238
+ }
222
239
  }
223
240
 
224
241
  return marksByIndex;
@@ -1,143 +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, press, settle, waitForText, waitForTextGone } 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("b cycles expanded -> compact -> hidden -> expanded", async () => {
53
- // Arrange
54
- const setup = await renderApp();
55
- // the plan frame widens as the rail shrinks: expanded < compact < hidden.
56
- // its width is the column of its top-right corner (the first ╮ on the row).
57
- const planWidth = () =>
58
- setup
59
- .captureCharFrame()
60
- .split("\n")
61
- .find((line) => line.includes("╮"))
62
- ?.indexOf("╮") ?? 0;
63
-
64
- // Assert: expanded shows the submit button and leaves room for the rail
65
- expect(setup.captureCharFrame()).toContain("Submit review");
66
- const expandedWidth = planWidth();
67
-
68
- // Act
69
- await press(setup, "b");
70
-
71
- // Assert: compact drops the submit button and widens the plan (rail is a strip)
72
- await waitForTextGone(setup, "Submit review");
73
- const compactWidth = planWidth();
74
-
75
- expect(compactWidth).toBeGreaterThan(expandedWidth);
76
-
77
- // Act
78
- await press(setup, "b");
79
- await settle(setup);
80
-
81
- // Assert: hidden spans the plan full width, no rail column
82
- const hiddenWidth = planWidth();
83
-
84
- expect(hiddenWidth).toBeGreaterThan(compactWidth);
85
- expect(hiddenWidth).toBeGreaterThan(115);
86
-
87
- // Act
88
- await press(setup, "b");
89
-
90
- // Assert
91
- await waitForText(setup, "Submit review"); // back to expanded
92
- });
93
-
94
- test("the collapse mode persists to the user config", async () => {
95
- // Arrange
96
- const setup = await renderApp();
97
-
98
- // Act
99
- await press(setup, "b"); // -> compact
100
-
101
- // Assert
102
- expect(loadConfig({ userConfigPath: configPath }).ui.reviewState).toBe("compact");
103
- });
104
-
105
- test("] widens the rail and persists the new width", async () => {
106
- // Arrange
107
- const setup = await renderApp();
108
- const startWidth = loadConfig({ userConfigPath: configPath }).ui.reviewWidth;
109
-
110
- // Act
111
- await press(setup, "]");
112
-
113
- // Assert
114
- const widened = loadConfig({ userConfigPath: configPath }).ui.reviewWidth;
115
-
116
- expect(widened).toBeGreaterThan(startWidth);
117
- });
118
-
119
- test("submitting from a hidden panel force-opens the rail so the confirm card is reachable", async () => {
120
- // Arrange
121
- const setup = await renderApp();
122
-
123
- await press(setup, "b"); // -> compact
124
- await press(setup, "b"); // -> hidden
125
-
126
- // Assert
127
- await waitForTextGone(setup, "Submit review"); // rail is gone
128
- expect(setup.captureCharFrame()).not.toContain("Submit review");
129
-
130
- // Act
131
- await press(setup, "enter"); // submit while hidden must not trap in an invisible modal
132
-
133
- // Assert
134
- await waitForText(setup, "submit review");
135
- const frame = setup.captureCharFrame();
136
-
137
- expect(frame).toContain("submit review"); // the confirm card is visible again
138
- expect(frame).toContain("[Approve]");
139
-
140
- // the reveal is live-only: the saved panel preference stays as the user left it
141
- expect(loadConfig({ userConfigPath: configPath }).ui.reviewState).toBe("hidden");
142
- });
143
- });
@@ -1,113 +0,0 @@
1
- import React from "react";
2
- import { DARK } from "../theme";
3
- import type { Story, StoryMeta } from "./story";
4
- import { buildDisplay } from "../view-plan";
5
- import { PlanSheet } from "./PlanSheet";
6
- import { FIXTURE_PLAN, fixtureDisplay, fixtureMarks, fixturePlanSession } from "./story-fixtures";
7
-
8
- export const meta: StoryMeta = { title: "PlanSheet" };
9
-
10
- const callbacks = { onLineActivate: () => {} };
11
-
12
- // a working copy with the context paragraph cut, so it renders as a del block
13
- const CUT_WORKING = FIXTURE_PLAN.replace("The daemon persists sessions to disk atomically.\n", "");
14
-
15
- export const AnnotatedPlan: Story = {
16
- render: () => (
17
- <PlanSheet
18
- session={fixturePlanSession()}
19
- display={fixtureDisplay()}
20
- marks={fixtureMarks()}
21
- cursor={2}
22
- activeSpan={null}
23
- compose={null}
24
- popover={null}
25
- editOrphanCount={0}
26
- {...callbacks}
27
- />
28
- ),
29
- expectedColors: [DARK.markCommentBackground, DARK.cursorBackground],
30
- size: { width: 90, height: 28 },
31
- };
32
-
33
- export const SpanSelection: Story = {
34
- render: () => (
35
- <PlanSheet
36
- session={fixturePlanSession({ annotations: [] })}
37
- display={fixtureDisplay()}
38
- marks={new Map()}
39
- cursor={2}
40
- activeSpan={{ displayIndex: 2, start: 0, end: 10 }}
41
- compose={null}
42
- popover={null}
43
- editOrphanCount={0}
44
- {...callbacks}
45
- />
46
- ),
47
- expectedColors: [DARK.accent],
48
- size: { width: 90, height: 28 },
49
- };
50
-
51
- export const InlineCompose: Story = {
52
- render: () => (
53
- <PlanSheet
54
- session={fixturePlanSession({ annotations: [] })}
55
- display={fixtureDisplay()}
56
- marks={new Map()}
57
- cursor={2}
58
- activeSpan={{ displayIndex: 2, start: 0, end: 10 }}
59
- compose={{
60
- kind: "comment",
61
- displayIndex: 2,
62
- quote: "The daemon",
63
- draft: { text: "Which daemon?", onInput: () => {}, onSave: () => {}, onCancel: () => {} },
64
- }}
65
- popover={null}
66
- editOrphanCount={0}
67
- {...callbacks}
68
- />
69
- ),
70
- size: { width: 90, height: 30 },
71
- };
72
-
73
- /** Span mode shows the marker-actions toolbar inline below the marked block. */
74
- // The marker popover floats as an absolute overlay; the virtual terminal used
75
- // for story snapshots cannot composite it, so it is covered by the standalone
76
- // MarkerPopover stories and a functional App test rather than a PlanSheet snapshot.
77
-
78
- /** A cut block renders struck-through and grayed inline - no red, no [cut] tag. */
79
- export const WithCutBlock: Story = {
80
- render: () => (
81
- <PlanSheet
82
- session={fixturePlanSession()}
83
- display={buildDisplay(FIXTURE_PLAN, CUT_WORKING)}
84
- marks={new Map()}
85
- cursor={0}
86
- activeSpan={null}
87
- compose={null}
88
- popover={null}
89
- editOrphanCount={0}
90
- {...callbacks}
91
- />
92
- ),
93
- expectedColors: [DARK.textDim],
94
- size: { width: 90, height: 28 },
95
- };
96
-
97
- export const OrphanBanner: Story = {
98
- render: () => (
99
- <PlanSheet
100
- session={fixturePlanSession()}
101
- display={fixtureDisplay()}
102
- marks={new Map()}
103
- cursor={0}
104
- activeSpan={null}
105
- compose={null}
106
- popover={null}
107
- editOrphanCount={1}
108
- {...callbacks}
109
- />
110
- ),
111
- expectedColors: [DARK.red],
112
- size: { width: 90, height: 28 },
113
- };