@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
package/src/share.ts CHANGED
@@ -12,8 +12,14 @@ import {
12
12
  SHARE_UPLOAD_USER,
13
13
  packSessionBlob,
14
14
  } from "@cueloop/daemon/share-blob";
15
- import type { Annotation, ReviewSession } from "@cueloop/schema";
15
+ import {
16
+ removalEntries,
17
+ viewFollowing,
18
+ type Annotation,
19
+ type ReviewSession,
20
+ } from "@cueloop/schema";
16
21
  import { SessionRecordSchema } from "@cueloop/daemon/validate";
22
+ import type { SharedMerge } from "@cueloop/daemon/client";
17
23
  import * as v from "valibot";
18
24
  import { copyToClipboard } from "./clipboard";
19
25
 
@@ -41,7 +47,8 @@ export async function publishShare(
41
47
  ): Promise<ShareResult> {
42
48
  const { stdout, stderr, code } = await runShareSsh(
43
49
  "cueloop-share",
44
- packSessionBlob(session),
50
+ // the share follows one branch: collaborators see its path wherever the owner stands
51
+ packSessionBlob(viewFollowing(session)),
45
52
  target,
46
53
  );
47
54
 
@@ -73,6 +80,25 @@ export function collaboratorAnnotations(session: ReviewSession): Annotation[] {
73
80
  return session.annotations.filter((annotation) => annotation.author);
74
81
  }
75
82
 
83
+ /**
84
+ * What a pulled share hands the local merge: collaborators' notes, the
85
+ * participant registry, and the removals the share recorded, by entry id.
86
+ */
87
+ export function mergeFromShare(remote: ReviewSession): SharedMerge {
88
+ const merge: SharedMerge = { annotations: collaboratorAnnotations(remote) };
89
+
90
+ if (remote.participants) merge.participants = remote.participants;
91
+ if (remote.history) {
92
+ merge.removals = removalEntries(remote.history).map((entry) => ({
93
+ id: entry.id,
94
+ annotationId: entry.annotationId,
95
+ createdAt: entry.createdAt,
96
+ }));
97
+ }
98
+
99
+ return merge;
100
+ }
101
+
76
102
  /**
77
103
  * Mirror the planner's own annotations up into the share, so collaborators see
78
104
  * them on their next refresh. Owner-gated at the gateway; the notes stay
@@ -92,26 +118,98 @@ export async function pushShare(
92
118
  if (code !== 0) throw new Error(`gateway push failed: ${stderr.trim() || `ssh exited ${code}`}`);
93
119
  }
94
120
 
121
+ export interface ShareWatchHandlers {
122
+ /** The whole session record, each time the share changes. */
123
+ onSession: (session: ReviewSession) => void;
124
+ /** The stream ended, for any reason; the caller decides whether to reconnect. */
125
+ onClose: (reason: string) => void;
126
+ }
127
+
128
+ const WatchFrameSchema = v.variant("type", [
129
+ v.object({ type: v.literal("ready") }),
130
+ v.object({ type: v.literal("ping") }),
131
+ v.object({ type: v.literal("session"), session: SessionRecordSchema }),
132
+ ]);
133
+
134
+ /**
135
+ * Follow a share live: one long-lived `cueloop-watch` ssh stream that carries
136
+ * a JSON line per change. Owner-gated at the gateway. Returns a stop handle.
137
+ */
138
+ export function watchShare(
139
+ shareId: string,
140
+ handlers: ShareWatchHandlers,
141
+ target: ShareTarget = {},
142
+ ): () => void {
143
+ const proc = Bun.spawn(shareSshCommand("cueloop-watch", target), {
144
+ stdin: "pipe",
145
+ stdout: "pipe",
146
+ stderr: "pipe",
147
+ });
148
+
149
+ proc.stdin.write(shareId);
150
+ proc.stdin.end();
151
+ void readLines(proc.stdout, (line) => {
152
+ const frame = v.safeParse(WatchFrameSchema, JSON.parse(line));
153
+
154
+ if (frame.success && frame.output.type === "session") handlers.onSession(frame.output.session);
155
+ }).catch(() => {});
156
+ void Promise.all([proc.exited, new Response(proc.stderr).text()]).then(([code, stderr]) =>
157
+ handlers.onClose(stderr.trim() || `ssh exited ${code}`),
158
+ );
159
+
160
+ return () => proc.kill();
161
+ }
162
+
163
+ /** Feed each complete line of a byte stream to `onLine`; a torn line is dropped. */
164
+ async function readLines(
165
+ stream: ReadableStream<Uint8Array>,
166
+ onLine: (line: string) => void,
167
+ ): Promise<void> {
168
+ const decoder = new TextDecoder();
169
+ let pending = "";
170
+
171
+ for await (const chunk of stream) {
172
+ pending += decoder.decode(chunk, { stream: true });
173
+ const lines = pending.split("\n");
174
+
175
+ pending = lines.pop() ?? "";
176
+ for (const line of lines) {
177
+ if (line.trim() === "") continue;
178
+ try {
179
+ onLine(line);
180
+ } catch {
181
+ // one bad line never ends the stream
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ function shareSshCommand(command: string, target: ShareTarget): string[] {
188
+ const host = target.host ?? DEFAULT_SHARE_HOST;
189
+ const port = target.port ?? DEFAULT_SHARE_PORT;
190
+
191
+ return [
192
+ "ssh",
193
+ "-p",
194
+ String(port),
195
+ "-o",
196
+ "StrictHostKeyChecking=accept-new",
197
+ `${SHARE_UPLOAD_USER}@${host}`,
198
+ command,
199
+ ];
200
+ }
201
+
95
202
  /** Run one `share@host:port` ssh command with `stdin`; hand back its streams and exit code. */
96
203
  async function runShareSsh(
97
204
  command: string,
98
205
  stdin: Buffer,
99
206
  target: ShareTarget,
100
207
  ): Promise<{ stdout: string; stderr: string; code: number }> {
101
- const host = target.host ?? DEFAULT_SHARE_HOST;
102
- const port = target.port ?? DEFAULT_SHARE_PORT;
103
- const proc = Bun.spawn(
104
- [
105
- "ssh",
106
- "-p",
107
- String(port),
108
- "-o",
109
- "StrictHostKeyChecking=accept-new",
110
- `${SHARE_UPLOAD_USER}@${host}`,
111
- command,
112
- ],
113
- { stdin, stdout: "pipe", stderr: "pipe" },
114
- );
208
+ const proc = Bun.spawn(shareSshCommand(command, target), {
209
+ stdin,
210
+ stdout: "pipe",
211
+ stderr: "pipe",
212
+ });
115
213
  const [stdout, stderr, code] = await Promise.all([
116
214
  new Response(proc.stdout).text(),
117
215
  new Response(proc.stderr).text(),
@@ -2,8 +2,22 @@ import { describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { createElement, type ReactElement } from "react";
6
+ import type { KeyEvent, MouseEvent } from "@opentui/core";
7
+ import type { TestRendererSetup } from "@opentui/core/testing";
8
+ import { useKeyboard } from "@opentui/react";
9
+ import { testRender } from "@opentui/react/test-utils";
5
10
  import { loadConfig } from "./config";
6
- import { isolateUserConfig } from "./test-support";
11
+ import {
12
+ clickText,
13
+ dragText,
14
+ frameRow,
15
+ isolateUserConfig,
16
+ locateText,
17
+ pressKey,
18
+ waitForText,
19
+ type FrameLocation,
20
+ } from "./test-support";
7
21
 
8
22
  describe("isolateUserConfig", () => {
9
23
  test("loadConfig sees defaults while isolated and the env is restored after", () => {
@@ -58,3 +72,181 @@ describe("isolateUserConfig", () => {
58
72
  }
59
73
  });
60
74
  });
75
+
76
+ interface RecordedKey {
77
+ name: string;
78
+ ctrl: boolean;
79
+ shift: boolean;
80
+ meta: boolean;
81
+ }
82
+
83
+ interface RecordedMouseEvent {
84
+ type: "down" | "up";
85
+ x: number;
86
+ y: number;
87
+ }
88
+
89
+ interface FixtureLog {
90
+ keys: RecordedKey[];
91
+ mouseEvents: RecordedMouseEvent[];
92
+ pressZoneClicks: number[];
93
+ }
94
+
95
+ /**
96
+ * A minimal interaction surface: one keyboard recorder, a root box recording
97
+ * down/up coordinates, and stacked text rows at known positions.
98
+ */
99
+ function InteractionFixture(properties: { log: FixtureLog }): ReactElement {
100
+ const { log } = properties;
101
+
102
+ useKeyboard((key: KeyEvent) => {
103
+ log.keys.push({ name: key.name, ctrl: key.ctrl, shift: key.shift, meta: key.meta });
104
+ });
105
+
106
+ return createElement(
107
+ "box",
108
+ {
109
+ width: "100%",
110
+ height: "100%",
111
+ flexDirection: "column",
112
+ onMouseDown: (event: MouseEvent) => {
113
+ log.mouseEvents.push({ type: "down", x: event.x, y: event.y });
114
+ },
115
+ onMouseUp: (event: MouseEvent) => {
116
+ log.mouseEvents.push({ type: "up", x: event.x, y: event.y });
117
+ },
118
+ },
119
+ createElement("text", { content: "header line" }),
120
+ createElement("text", { content: " shifted needle" }),
121
+ createElement("text", { content: " drag from here" }),
122
+ createElement("text", { content: " drop to here" }),
123
+ createElement("text", {
124
+ content: "press zone",
125
+ onMouseDown: (event: MouseEvent) => {
126
+ log.pressZoneClicks.push(event.x);
127
+ },
128
+ }),
129
+ );
130
+ }
131
+
132
+ async function renderFixture(): Promise<{ setup: TestRendererSetup; log: FixtureLog }> {
133
+ const log: FixtureLog = { keys: [], mouseEvents: [], pressZoneClicks: [] };
134
+ const setup = await testRender(createElement(InteractionFixture, { log }), {
135
+ width: 40,
136
+ height: 8,
137
+ });
138
+
139
+ await waitForText(setup, "press zone");
140
+
141
+ return { setup, log };
142
+ }
143
+
144
+ describe("pressKey", () => {
145
+ test("a ctrl+letter chord reaches the keyboard handler with ctrl set", async () => {
146
+ // Arrange
147
+ const { setup, log } = await renderFixture();
148
+
149
+ // Act
150
+ await pressKey(setup, "o", { ctrl: true });
151
+
152
+ // Assert
153
+ expect(log.keys).toEqual([{ name: "o", ctrl: true, shift: false, meta: false }]);
154
+ });
155
+
156
+ test("a named key with a modifier arrives as the modified key", async () => {
157
+ // Arrange
158
+ const { setup, log } = await renderFixture();
159
+
160
+ // Act
161
+ await pressKey(setup, "ARROW_RIGHT", { shift: true });
162
+
163
+ // Assert
164
+ expect(log.keys).toEqual([{ name: "right", ctrl: false, shift: true, meta: false }]);
165
+ });
166
+ });
167
+
168
+ describe("locateText", () => {
169
+ test("returns the 0-based row and start column of the first occurrence", async () => {
170
+ // Arrange
171
+ const { setup } = await renderFixture();
172
+
173
+ // Act
174
+ const located = locateText(setup, "shifted needle");
175
+
176
+ // Assert
177
+ expect(located).toEqual({ row: 1, column: 3 });
178
+ });
179
+
180
+ test("a miss throws with the needle and the full frame in the message", async () => {
181
+ // Arrange
182
+ const { setup } = await renderFixture();
183
+
184
+ // Act
185
+ const locateMissing = (): FrameLocation => locateText(setup, "absent needle");
186
+
187
+ // Assert
188
+ expect(locateMissing).toThrow('"absent needle"');
189
+ expect(locateMissing).toThrow("header line");
190
+ });
191
+ });
192
+
193
+ describe("frameRow", () => {
194
+ test("returns rows that reflect the vertical layout", async () => {
195
+ // Arrange
196
+ const { setup } = await renderFixture();
197
+
198
+ // Act / Assert
199
+ expect(frameRow(setup, "header line")).toBe(0);
200
+ expect(frameRow(setup, "drop to here")).toBe(3);
201
+ expect(frameRow(setup, "press zone")).toBe(4);
202
+ });
203
+ });
204
+
205
+ describe("clickText", () => {
206
+ test("lands the click on the element rendering the needle", async () => {
207
+ // Arrange
208
+ const { setup, log } = await renderFixture();
209
+
210
+ // Act
211
+ await clickText(setup, "press zone");
212
+
213
+ // Assert
214
+ expect(log.pressZoneClicks).toEqual([0]);
215
+ });
216
+
217
+ test("charOffset shifts the click within the needle", async () => {
218
+ // Arrange
219
+ const { setup, log } = await renderFixture();
220
+
221
+ // Act
222
+ await clickText(setup, "press zone", 6);
223
+
224
+ // Assert
225
+ expect(log.pressZoneClicks).toEqual([6]);
226
+ });
227
+ });
228
+
229
+ describe("dragText", () => {
230
+ test("delivers the down at the from-text and the up at the to-text", async () => {
231
+ // Arrange
232
+ const { setup, log } = await renderFixture();
233
+
234
+ // Act
235
+ await dragText(setup, "drag from here", "drop to here");
236
+
237
+ // Assert
238
+ expect(log.mouseEvents).toContainEqual({ type: "down", x: 2, y: 2 });
239
+ expect(log.mouseEvents).toContainEqual({ type: "up", x: 5, y: 3 });
240
+ });
241
+
242
+ test("an end offset lands the up that many characters past the to-text", async () => {
243
+ // Arrange
244
+ const { setup, log } = await renderFixture();
245
+
246
+ // Act
247
+ await dragText(setup, "drag from here", "drop to here", 4);
248
+
249
+ // Assert
250
+ expect(log.mouseEvents).toContainEqual({ type: "up", x: 9, y: 3 });
251
+ });
252
+ });
@@ -67,6 +67,70 @@ export async function typeText(setup: TestRendererSetup, text: string): Promise<
67
67
  await settle(setup);
68
68
  }
69
69
 
70
+ /** Drive one key press with modifiers: KeyCodes names ("ARROW_RIGHT") or single letters (ctrl+o). */
71
+ export async function pressKey(
72
+ setup: TestRendererSetup,
73
+ key: string,
74
+ modifiers?: { shift?: boolean; ctrl?: boolean; meta?: boolean },
75
+ ): Promise<void> {
76
+ setup.mockInput.pressKey(key, modifiers);
77
+ await settle(setup);
78
+ }
79
+
80
+ /** A 0-based cell position within a captured char frame. */
81
+ export interface FrameLocation {
82
+ row: number;
83
+ column: number;
84
+ }
85
+
86
+ /** Locate the first visual line containing the needle: 0-based row and start column. */
87
+ export function locateText(setup: TestRendererSetup, needle: string): FrameLocation {
88
+ const frame = setup.captureCharFrame();
89
+
90
+ for (const [row, line] of frame.split("\n").entries()) {
91
+ const column = line.indexOf(needle);
92
+
93
+ if (column !== -1) return { row, column };
94
+ }
95
+
96
+ throw new Error(`locateText ${JSON.stringify(needle)} not found.\nframe:\n${frame}`);
97
+ }
98
+
99
+ /** The 0-based visual row of the needle - for relative vertical-layout assertions. */
100
+ export function frameRow(setup: TestRendererSetup, needle: string): number {
101
+ return locateText(setup, needle).row;
102
+ }
103
+
104
+ /** Click the first on-screen occurrence of the needle, offset in characters from its start. */
105
+ export async function clickText(
106
+ setup: TestRendererSetup,
107
+ needle: string,
108
+ charOffset = 0,
109
+ ): Promise<void> {
110
+ const { row, column } = locateText(setup, needle);
111
+
112
+ await setup.mockMouse.click(column + charOffset, row);
113
+ await settle(setup);
114
+ }
115
+
116
+ /**
117
+ * Drag from the first occurrence of one needle to the first occurrence of
118
+ * another, `toCharOffset` characters past the second needle's start - so a
119
+ * character-precise selection can end inside or right after a word.
120
+ */
121
+ export async function dragText(
122
+ setup: TestRendererSetup,
123
+ fromNeedle: string,
124
+ toNeedle: string,
125
+ toCharOffset = 0,
126
+ ): Promise<void> {
127
+ const from = locateText(setup, fromNeedle);
128
+ const to = locateText(setup, toNeedle);
129
+
130
+ await setup.mockMouse.drag(from.column, from.row, to.column + toCharOffset, to.row);
131
+ await settle(setup);
132
+ }
133
+
70
134
  // Below the 60s per-test budget so a genuinely stuck wait throws its frame
71
135
  // error (useful) before bun's bare "timed out" fires. Generous because a
72
136
  // contended CI runner starves the render loop and daemon round-trips.
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",
@@ -0,0 +1,154 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resolveThreadChord } from "./thread-chords";
3
+
4
+ const owner = { composing: false, isOwner: true, resolved: false, treeActive: false };
5
+
6
+ describe("resolveThreadChord", () => {
7
+ test("ctrl+enter opens the submit overlay for an owner with an open review", () => {
8
+ // Assert: the terminal-safe chord and the cmd forms all submit
9
+ expect(resolveThreadChord({ name: "return", ctrl: true }, owner)).toEqual({
10
+ type: "openSubmit",
11
+ });
12
+ expect(resolveThreadChord({ name: "return", super: true }, owner)).toEqual({
13
+ type: "openSubmit",
14
+ });
15
+ expect(resolveThreadChord({ name: "enter", meta: true }, owner)).toEqual({
16
+ type: "openSubmit",
17
+ });
18
+ });
19
+
20
+ test("session chords map to their intents", () => {
21
+ // Assert
22
+ expect(resolveThreadChord({ name: "e", ctrl: true }, owner)).toEqual({ type: "edit" });
23
+ expect(resolveThreadChord({ name: "s", ctrl: true }, owner)).toEqual({ type: "share" });
24
+ expect(resolveThreadChord({ name: "r", ctrl: true }, owner)).toEqual({
25
+ type: "cycleReviewPanel",
26
+ });
27
+ });
28
+
29
+ test("option chords drive the rail and curation with the plan sheet's letters", () => {
30
+ // Assert
31
+ expect(resolveThreadChord({ name: "n", meta: true }, owner)).toEqual({
32
+ type: "nextAnnotation",
33
+ });
34
+ expect(resolveThreadChord({ name: "p", meta: true }, owner)).toEqual({
35
+ type: "prevAnnotation",
36
+ });
37
+ expect(resolveThreadChord({ name: "e", meta: true }, owner)).toEqual({ type: "editCard" });
38
+ expect(resolveThreadChord({ name: "backspace", meta: true }, owner)).toEqual({
39
+ type: "removeAnnotation",
40
+ });
41
+ expect(resolveThreadChord({ name: "r", meta: true }, owner)).toEqual({ type: "openRename" });
42
+ expect(resolveThreadChord({ name: "x", meta: true }, owner)).toEqual({ type: "cut" });
43
+ expect(resolveThreadChord({ name: "u", meta: true }, owner)).toEqual({
44
+ type: "restoreCuration",
45
+ });
46
+ expect(resolveThreadChord({ name: "w", meta: true }, owner)).toEqual({
47
+ type: "resizeReviewPanel",
48
+ direction: 1,
49
+ });
50
+ expect(resolveThreadChord({ name: "x", meta: true }, { ...owner, isOwner: false })).toEqual({
51
+ type: "status",
52
+ message: "observer - read-only",
53
+ });
54
+ });
55
+
56
+ test("an open composer swallows every chord, so typing is never hijacked", () => {
57
+ // Assert
58
+ expect(
59
+ resolveThreadChord({ name: "return", ctrl: true }, { ...owner, composing: true }),
60
+ ).toBeNull();
61
+ expect(resolveThreadChord({ name: "s", ctrl: true }, { ...owner, composing: true })).toBeNull();
62
+ });
63
+
64
+ test("plain keys and unbound chords belong to the document grammar", () => {
65
+ // Assert
66
+ expect(resolveThreadChord({ name: "return" }, owner)).toBeNull();
67
+ expect(resolveThreadChord({ name: "s" }, owner)).toBeNull();
68
+ expect(resolveThreadChord({ name: "x", ctrl: true }, owner)).toBeNull();
69
+ });
70
+
71
+ test("a collaborator is told a primitive is read-only, and a resolved review cannot be resubmitted", () => {
72
+ // Assert
73
+ const collaborator = { ...owner, isOwner: false };
74
+ const readOnly = { type: "status" as const, message: "observer - read-only" };
75
+
76
+ expect(resolveThreadChord({ name: "return", ctrl: true }, collaborator)).toEqual(readOnly);
77
+ expect(resolveThreadChord({ name: "e", ctrl: true }, collaborator)).toEqual(readOnly);
78
+ expect(resolveThreadChord({ name: "s", ctrl: true }, collaborator)).toEqual(readOnly);
79
+ expect(resolveThreadChord({ name: "r", ctrl: true }, collaborator)).toEqual({
80
+ type: "cycleReviewPanel",
81
+ });
82
+ expect(
83
+ resolveThreadChord({ name: "return", ctrl: true }, { ...owner, resolved: true }),
84
+ ).toBeNull();
85
+ });
86
+
87
+ test("a resolved review refuses every change and says so; renaming an author stays open", () => {
88
+ // Arrange
89
+ const resolved = { ...owner, resolved: true };
90
+ const submitted = { type: "status" as const, message: "review submitted - read-only" };
91
+
92
+ // Assert
93
+ expect(resolveThreadChord({ name: "e", ctrl: true }, resolved)).toEqual(submitted);
94
+ expect(resolveThreadChord({ name: "e", meta: true }, resolved)).toEqual(submitted);
95
+ expect(resolveThreadChord({ name: "backspace", meta: true }, resolved)).toEqual(submitted);
96
+ expect(resolveThreadChord({ name: "x", meta: true }, resolved)).toEqual(submitted);
97
+ expect(resolveThreadChord({ name: "u", meta: true }, resolved)).toEqual(submitted);
98
+ expect(resolveThreadChord({ name: "r", meta: true }, resolved)).toEqual({ type: "openRename" });
99
+ expect(resolveThreadChord({ name: "s", ctrl: true }, resolved)).toEqual({ type: "share" });
100
+ });
101
+
102
+ test("the tree chords: toggle, move on the Tree tab, go, branch, label, fork, hand off", () => {
103
+ // Arrange
104
+ const onTree = { ...owner, treeActive: true };
105
+ const readOnly = { type: "status" as const, message: "observer - read-only" };
106
+ const submitted = { type: "status" as const, message: "review submitted - read-only" };
107
+
108
+ // Assert: next / previous follow the tab that shows
109
+ expect(resolveThreadChord({ name: "t", meta: true }, owner)).toEqual({ type: "toggleTree" });
110
+ expect(resolveThreadChord({ name: "n", meta: true }, onTree)).toEqual({
111
+ type: "treeMove",
112
+ direction: 1,
113
+ });
114
+ expect(resolveThreadChord({ name: "p", meta: true }, onTree)).toEqual({
115
+ type: "treeMove",
116
+ direction: -1,
117
+ });
118
+ expect(resolveThreadChord({ name: "n", meta: true }, owner)).toEqual({
119
+ type: "nextAnnotation",
120
+ });
121
+ expect(resolveThreadChord({ name: "g", meta: true }, owner)).toEqual({ type: "treeGo" });
122
+ expect(resolveThreadChord({ name: "b", meta: true }, owner)).toEqual({ type: "treeBranch" });
123
+ expect(resolveThreadChord({ name: "l", meta: true }, owner)).toEqual({ type: "treeLabel" });
124
+ expect(resolveThreadChord({ name: "f", meta: true }, owner)).toEqual({ type: "treeFork" });
125
+ expect(resolveThreadChord({ name: "h", meta: true }, owner)).toEqual({ type: "treeForkShare" });
126
+ // moving the tree is the owner's; a fork is open after a verdict, a move is not
127
+ expect(resolveThreadChord({ name: "g", meta: true }, { ...owner, isOwner: false })).toEqual(
128
+ readOnly,
129
+ );
130
+ expect(resolveThreadChord({ name: "f", meta: true }, { ...owner, isOwner: false })).toEqual(
131
+ readOnly,
132
+ );
133
+ expect(resolveThreadChord({ name: "b", meta: true }, { ...owner, resolved: true })).toEqual(
134
+ submitted,
135
+ );
136
+ expect(resolveThreadChord({ name: "f", meta: true }, { ...owner, resolved: true })).toEqual({
137
+ type: "treeFork",
138
+ });
139
+ expect(resolveThreadChord({ name: "t", meta: true }, { ...owner, isOwner: false })).toEqual({
140
+ type: "toggleTree",
141
+ });
142
+ });
143
+
144
+ test("an observer cannot edit or delete cards either", () => {
145
+ // Arrange
146
+ const observer = { ...owner, isOwner: false };
147
+ const readOnly = { type: "status" as const, message: "observer - read-only" };
148
+
149
+ // Assert
150
+ expect(resolveThreadChord({ name: "e", meta: true }, observer)).toEqual(readOnly);
151
+ expect(resolveThreadChord({ name: "backspace", meta: true }, observer)).toEqual(readOnly);
152
+ expect(resolveThreadChord({ name: "r", meta: true }, observer)).toEqual({ type: "openRename" });
153
+ });
154
+ });