@cueloop/client 0.1.0-alpha.68 → 0.1.0-alpha.69

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 (82) hide show
  1. package/package.json +7 -6
  2. package/src/App.appearance.test.tsx +8 -6
  3. package/src/App.diff.test.tsx +3 -3
  4. package/src/App.export.test.tsx +9 -3
  5. package/src/App.plan-review.test.tsx +10 -8
  6. package/src/App.prototype-markdown.test.tsx +92 -0
  7. package/src/App.prototype.test.tsx +17 -28
  8. package/src/App.readonly.test.tsx +3 -3
  9. package/src/App.rendering.test.tsx +3 -4
  10. package/src/App.test.tsx +14 -18
  11. package/src/App.tsx +117 -42
  12. package/src/App.walk.test.tsx +2 -2
  13. package/src/App.workbench.test.tsx +85 -14
  14. package/src/app-screens.tsx +26 -10
  15. package/src/app-view-model.ts +17 -3
  16. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  17. package/src/components/AnnotatableFileView.test.tsx +102 -0
  18. package/src/components/AnnotatableFileView.tsx +126 -0
  19. package/src/components/AnnotationCards.tsx +6 -6
  20. package/src/components/AppShell.tsx +62 -15
  21. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  22. package/src/components/BareWorkbenchFileView.tsx +69 -0
  23. package/src/components/ChangesColumn.tsx +3 -2
  24. package/src/components/{DiffSheet.scroll.test.tsx → DiffContentView.scroll.test.tsx} +2 -2
  25. package/src/components/{DiffSheet.split.test.tsx → DiffContentView.split.test.tsx} +3 -3
  26. package/src/components/{DiffSheet.stories.tsx → DiffContentView.stories.tsx} +3 -3
  27. package/src/components/{DiffSheet.syntax.test.tsx → DiffContentView.syntax.test.tsx} +2 -2
  28. package/src/components/{DiffSheet.tsx → DiffContentView.tsx} +187 -28
  29. package/src/components/DiscussionMarkerRail.tsx +2 -2
  30. package/src/components/EditorGrid.tabs.test.tsx +17 -4
  31. package/src/components/EditorGrid.tsx +49 -19
  32. package/src/components/FileContentsView.test.tsx +3 -3
  33. package/src/components/FileContentsView.tsx +37 -17
  34. package/src/components/{SurfaceScrollbar.stories.tsx → OverlayScrollbar.stories.tsx} +5 -6
  35. package/src/components/{SurfaceScrollbar.test.tsx → OverlayScrollbar.test.tsx} +41 -13
  36. package/src/components/{SurfaceScrollbar.tsx → OverlayScrollbar.tsx} +49 -13
  37. package/src/components/ProjectTreeView.tsx +3 -2
  38. package/src/components/{PrototypeSheet.stories.tsx → PrototypeContentView.stories.tsx} +4 -5
  39. package/src/components/{PrototypeSheet.tsx → PrototypeContentView.tsx} +58 -58
  40. package/src/components/ScrollArea.stories.tsx +20 -0
  41. package/src/components/ScrollArea.tsx +44 -0
  42. package/src/components/SettingsDialog.tsx +3 -2
  43. package/src/components/{InboxList.stories.tsx → ThreadTree.stories.tsx} +5 -5
  44. package/src/components/{InboxList.tsx → ThreadTree.tsx} +6 -6
  45. package/src/components/ThreadsSidebar.tsx +5 -4
  46. package/src/components/TreePane.tsx +3 -2
  47. package/src/components/WelcomePlayground.stories.tsx +12 -0
  48. package/src/components/WelcomePlayground.test.tsx +47 -0
  49. package/src/components/WelcomePlayground.tsx +140 -0
  50. package/src/components/__snapshots__/stories.test.tsx.snap +163 -283
  51. package/src/components/{diff-sheet-layout.test.ts → diff-content-view-layout.test.ts} +1 -1
  52. package/src/components/{diff-sheet-layout.ts → diff-content-view-layout.ts} +1 -1
  53. package/src/config.test.ts +19 -6
  54. package/src/config.ts +26 -6
  55. package/src/intent-dispatch.test.ts +2 -0
  56. package/src/intent-dispatch.ts +1 -1
  57. package/src/key-bindings.test.ts +1 -1
  58. package/src/key-bindings.ts +5 -1
  59. package/src/keymap.ts +1 -1
  60. package/src/prototype-browser.ts +8 -0
  61. package/src/prototype-pixels.tsx +35 -0
  62. package/src/ready-signal.test.ts +85 -0
  63. package/src/ready-signal.ts +52 -0
  64. package/src/session-controller.live-diff.test.ts +198 -0
  65. package/src/session-controller.ts +141 -26
  66. package/src/slash-palette.test.ts +54 -0
  67. package/src/slash-palette.ts +32 -18
  68. package/src/test-support.ts +42 -3
  69. package/src/theme-presets.ts +20 -0
  70. package/src/theme.ts +18 -0
  71. package/src/thread-chords.ts +1 -1
  72. package/src/use-annotation-surface.tsx +12 -2
  73. package/src/use-settings-dialog.tsx +4 -4
  74. package/src/view-diff.test.ts +44 -1
  75. package/src/view-diff.ts +81 -0
  76. package/src/components/AnnotationCard.compose-rows.test.ts +0 -49
  77. package/src/components/AnnotationCard.stories.tsx +0 -94
  78. package/src/components/AnnotationCard.tsx +0 -250
  79. package/src/components/MarkerPopover.stories.tsx +0 -58
  80. package/src/components/MarkerPopover.tsx +0 -109
  81. package/src/components/WelcomeSurface.stories.tsx +0 -12
  82. package/src/components/WelcomeSurface.tsx +0 -52
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Pure color logic for the diff review sheet, split out so it can be tested
3
3
  * without rendering: a row's colored spans (syntax under the intra-line change
4
- * color). DiffSheet.tsx paints marks and the caret over these per character.
4
+ * color). DiffContentView.tsx paints marks and the caret over these per character.
5
5
  */
6
6
 
7
7
  import type { Theme } from "../theme";
@@ -126,19 +126,32 @@ describe("loadConfig", () => {
126
126
  }
127
127
  });
128
128
 
129
- test("[ui] diff_view defaults to unified and parses split", () => {
129
+ test("[ui] diff_view defaults to split and parses stacked", () => {
130
130
  // Arrange
131
131
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-diff-"));
132
132
  const path = join(dir, "config.toml");
133
133
 
134
- writeFileSync(path, `[ui]\ndiff_view = "split"\n`);
134
+ writeFileSync(path, `[ui]\ndiff_view = "stacked"\n`);
135
135
 
136
136
  try {
137
137
  // Assert
138
- expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.diffView).toBe(
139
- "unified",
140
- );
141
- expect(loadConfig({ userConfigPath: path }).ui.diffView).toBe("split");
138
+ expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.diffView).toBe("split");
139
+ expect(loadConfig({ userConfigPath: path }).ui.diffView).toBe("stacked");
140
+ } finally {
141
+ rmSync(dir, { recursive: true, force: true });
142
+ }
143
+ });
144
+
145
+ test('[ui] diff_view loads the pre-rename "unified" value as "stacked"', () => {
146
+ // Arrange - an upgrade must not flip a user who had persisted the old spelling
147
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-diff-"));
148
+ const path = join(dir, "config.toml");
149
+
150
+ writeFileSync(path, `[ui]\ndiff_view = "unified"\n`);
151
+
152
+ try {
153
+ // Assert
154
+ expect(loadConfig({ userConfigPath: path }).ui.diffView).toBe("stacked");
142
155
  } finally {
143
156
  rmSync(dir, { recursive: true, force: true });
144
157
  }
package/src/config.ts CHANGED
@@ -26,8 +26,9 @@ export interface IntegrationsConfig {
26
26
  /** Post-submit behavior: "off" prompts, 0 closes instantly, N counts down. */
27
27
  export type AutoClose = "off" | number;
28
28
 
29
- /** How the Changes diff renders: one inline column, or old|new side by side (only when zoomed). */
30
- export type DiffViewMode = "unified" | "split";
29
+ /** How the Changes diff renders when wide: old|new side by side, or one stacked column. A narrow
30
+ * (unzoomed) pane has no room for two columns, so it is always stacked regardless of this choice. */
31
+ export type DiffViewMode = "split" | "stacked";
31
32
 
32
33
  /** One marker-popover quick action: a preset comment body, plus optional extra lines. */
33
34
  export interface QuickAction {
@@ -99,7 +100,7 @@ export interface CueloopConfig {
99
100
  editor?: string;
100
101
  /** The selected theme preset name; its tokens are the base for `theme`, before any `[theme]` overrides. */
101
102
  theme: ThemeName;
102
- /** How the Changes diff renders when zoomed: one inline column or old|new side by side. */
103
+ /** How the Changes diff renders when wide: old|new side by side or one stacked column. */
103
104
  diffView: DiffViewMode;
104
105
  /** Session ids the user has pinned to the top of the sidebar; client-local view state. */
105
106
  pins: string[];
@@ -109,6 +110,13 @@ export interface CueloopConfig {
109
110
  /** Marker-popover quick actions ([[actions]] tables); the 5 defaults when unset. */
110
111
  actions: QuickAction[];
111
112
  integrations: IntegrationsConfig;
113
+ /** Opt-in experimental features ([experimental] table); all default off. */
114
+ experimental: ExperimentalConfig;
115
+ }
116
+
117
+ export interface ExperimentalConfig {
118
+ /** Render a prototype as a pixel mockup (kitty graphics) instead of the default markdown design doc. */
119
+ prototypePixels: boolean;
112
120
  }
113
121
 
114
122
  /** Every action in the grammar, with its default binding(s). */
@@ -143,6 +151,7 @@ const ConfigDocumentSchema = v.object({
143
151
  keys: v.optional(v.unknown()),
144
152
  theme: v.optional(v.unknown()),
145
153
  ui: v.optional(v.unknown()),
154
+ experimental: v.optional(v.unknown()),
146
155
  });
147
156
 
148
157
  const QuickActionSchema = v.object({
@@ -165,7 +174,7 @@ const UiSchema = v.object({
165
174
  ),
166
175
  editor: v.fallback(v.optional(v.string()), undefined),
167
176
  theme: v.fallback(v.optional(v.string()), undefined),
168
- diff_view: v.fallback(v.optional(v.picklist(["unified", "split"])), undefined),
177
+ diff_view: v.fallback(v.optional(v.picklist(["split", "stacked", "unified"])), undefined),
169
178
  pins: v.fallback(v.optional(v.array(v.string())), undefined),
170
179
  });
171
180
  const ObsidianSchema = v.object({
@@ -176,6 +185,9 @@ const ObsidianSchema = v.object({
176
185
  exportOn: v.fallback(v.optional(v.picklist(["approve", "resolve", "manual"])), undefined),
177
186
  });
178
187
  const IntegrationsSchema = v.object({ obsidian: v.optional(ObsidianSchema) });
188
+ const ExperimentalSchema = v.object({
189
+ prototype_pixels: v.fallback(v.optional(v.boolean()), undefined),
190
+ });
179
191
  const ThemeOverridesSchema = v.record(v.string(), v.unknown());
180
192
 
181
193
  function isThemeToken(token: string): token is keyof Theme {
@@ -225,12 +237,14 @@ function layer(
225
237
  authors: { ...base.authors },
226
238
  actions: [...base.actions],
227
239
  integrations: { obsidian: { ...base.integrations.obsidian } },
240
+ experimental: { ...base.experimental },
228
241
  };
229
242
  const actions = parseActions(raw.actions);
230
243
  const authors = v.safeParse(AuthorsSchema, raw.authors);
231
244
  const keys = v.safeParse(KeysSchema, raw.keys);
232
245
  const ui = v.safeParse(UiSchema, raw.ui);
233
246
  const integrations = v.safeParse(IntegrationsSchema, raw.integrations);
247
+ const experimental = v.safeParse(ExperimentalSchema, raw.experimental);
234
248
 
235
249
  if (actions) out.actions = actions;
236
250
  if (authors.success) {
@@ -251,12 +265,17 @@ function layer(
251
265
  if (ui.success) {
252
266
  if (ui.output.auto_close !== undefined) out.ui.autoClose = ui.output.auto_close;
253
267
  if (ui.output.editor?.trim()) out.ui.editor = ui.output.editor.trim();
254
- if (ui.output.diff_view !== undefined) out.ui.diffView = ui.output.diff_view;
268
+ // "unified" is the pre-rename spelling of "stacked"; keep loading it so an upgrade never flips the layout
269
+ if (ui.output.diff_view !== undefined)
270
+ out.ui.diffView = ui.output.diff_view === "unified" ? "stacked" : ui.output.diff_view;
255
271
  if (ui.output.pins !== undefined) out.ui.pins = ui.output.pins;
256
272
  }
257
273
  if (integrations.success && integrations.output.obsidian) {
258
274
  mergeObsidian(out.integrations.obsidian, integrations.output.obsidian);
259
275
  }
276
+ if (experimental.success && experimental.output.prototype_pixels !== undefined) {
277
+ out.experimental.prototypePixels = experimental.output.prototype_pixels;
278
+ }
260
279
 
261
280
  return out;
262
281
  }
@@ -271,12 +290,13 @@ export function loadConfig(
271
290
  ui: {
272
291
  autoClose: "off",
273
292
  theme: DEFAULT_THEME_NAME,
274
- diffView: "unified",
293
+ diffView: "split",
275
294
  pins: [],
276
295
  },
277
296
  authors: {},
278
297
  actions: [...DEFAULT_QUICK_ACTIONS],
279
298
  integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
299
+ experimental: { prototypePixels: false },
280
300
  };
281
301
  // Theme name and per-token overrides are separate concerns, composed once
282
302
  // after all layers: the last file to set [ui] theme wins, and every [theme]
@@ -97,6 +97,8 @@ function baseController(): ReviewController {
97
97
  restoreCuration: mock(),
98
98
  edit: mock(),
99
99
  annotate: mock(() => undefined),
100
+ addComment: mock(() => undefined),
101
+ commentOnWorkbench: mock(() => Promise.resolve()),
100
102
  reply: mock(() => undefined),
101
103
  annotatePrototype: mock(() => undefined),
102
104
  updateAnnotation: mock(),
@@ -113,7 +113,7 @@ export interface IntentDispatchDeps {
113
113
  selectCardFromDocument: (annotationId: string) => void;
114
114
  runEditorHandOff: () => void;
115
115
  openCardEdit: (annotationId: string) => void;
116
- /** Flip unified/split diff and persist it (App-owned); split lays out only when zoomed. */
116
+ /** Flip split/stacked diff and persist it (App-owned); split lays out only when wide/zoomed. */
117
117
  toggleDiffView: () => void;
118
118
  }
119
119
 
@@ -115,7 +115,7 @@ describe("getActiveKeys-generated status hints", () => {
115
115
 
116
116
  test("submit hint", () => {
117
117
  expect(bindings({ overlay: "submit" }).statusHint("submit")).toBe(
118
- "verdict ←/→ · enter submit · esc cancel",
118
+ "decision ←/→ · enter submit · esc cancel",
119
119
  );
120
120
  });
121
121
 
@@ -92,7 +92,11 @@ const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
92
92
  { commands: ["cancel_overlay"], label: "cancel" },
93
93
  ],
94
94
  submit: [
95
- { commands: ["cycle_verdict_left", "cycle_verdict_right"], label: "verdict", labelFirst: true },
95
+ {
96
+ commands: ["cycle_verdict_left", "cycle_verdict_right"],
97
+ label: "decision",
98
+ labelFirst: true,
99
+ },
96
100
  { commands: ["submit_verdict"], label: "submit" },
97
101
  { commands: ["cancel_submit"], label: "cancel" },
98
102
  ],
package/src/keymap.ts CHANGED
@@ -372,7 +372,7 @@ function navigationIntent(action: string | undefined): Intent[] | null {
372
372
  }
373
373
 
374
374
  /** Ungated view toggles in the diff: right folds a file to its band, left unfolds it, s flips
375
- * unified/split (split lays out only when the Changes pane is zoomed). */
375
+ * split/stacked (split lays out only when the Changes pane is wide/zoomed). */
376
376
  function viewToggleIntent(action: string | undefined): Intent[] | null {
377
377
  if (action === "collapse_file") return [{ type: "foldFile" }];
378
378
  if (action === "expand_file") return [{ type: "unfoldFile" }];
@@ -42,6 +42,9 @@ export interface LaunchOptions {
42
42
  deviceScaleFactor?: number;
43
43
  /** Absolute path to a Chrome/Chromium binary; falls back to the channel. */
44
44
  executablePath?: string;
45
+ /** The terminal's color scheme, emulated as prefers-color-scheme so a mockup
46
+ * with theme-aware CSS renders to match the surface it emerges into. */
47
+ colorScheme?: "dark" | "light";
45
48
  }
46
49
 
47
50
  /** Cell click -> CSS pixel inside the letterboxed image, or null when outside it. */
@@ -145,6 +148,11 @@ export async function launchPrototypeRenderer(options: LaunchOptions): Promise<P
145
148
  height: options.viewport.height,
146
149
  deviceScaleFactor: options.deviceScaleFactor ?? 2,
147
150
  });
151
+ // match the terminal's scheme so a theme-aware mockup renders its dark or
152
+ // light variant to sit correctly on the surface it emerges into
153
+ await page.emulateMediaFeatures([
154
+ { name: "prefers-color-scheme", value: options.colorScheme ?? "light" },
155
+ ]);
148
156
  // `load` waits for images and styles but skips networkidle0's fixed 500ms
149
157
  // idle window, which a static local file would otherwise always pay
150
158
  await page.goto(pathToFileURL(options.filePath).href, { waitUntil: "load" });
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Lazy boundary for the opt-in kitty pixel prototype. The pixel path pulls in the
3
+ * kitty image writer, the headless-browser renderer, and (on first paint) puppeteer;
4
+ * none of that belongs in the runtime unless the `[experimental] prototype_pixels`
5
+ * flag is on. Importing only the TYPE here is erased at build time, so the real
6
+ * module - and its dependencies - loads only when this component actually mounts,
7
+ * which App does exclusively for a pixel prototype. This lives outside components/
8
+ * on purpose: it is an app boundary, not a visual component the story catalog covers.
9
+ */
10
+
11
+ import React, { useEffect, useState } from "react";
12
+ import type { PrototypeContentViewProps } from "./components/PrototypeContentView";
13
+
14
+ type PixelPrototypeView =
15
+ (typeof import("./components/PrototypeContentView"))["PrototypeContentView"];
16
+
17
+ export function PrototypePixels(props: PrototypeContentViewProps): React.ReactNode {
18
+ const [View, setView] = useState<PixelPrototypeView | null>(null);
19
+
20
+ useEffect(() => {
21
+ let alive = true;
22
+
23
+ void import("./components/PrototypeContentView").then((module) => {
24
+ if (alive) setView(() => module.PrototypeContentView);
25
+ });
26
+
27
+ return () => {
28
+ alive = false;
29
+ };
30
+ }, []);
31
+
32
+ if (!View) return null;
33
+
34
+ return <View {...props} />;
35
+ }
@@ -0,0 +1,85 @@
1
+ /** The ready signal fires once, after a frame, and writes the ready file when the env names one. */
2
+
3
+ import { afterEach, describe, expect, test } from "bun:test";
4
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { createElement, useState } from "react";
8
+ import { testRender } from "@opentui/react/test-utils";
9
+ import { READY_FILE_ENV, useReadySignal } from "./ready-signal";
10
+ import { settle } from "./test-support";
11
+
12
+ const homes: string[] = [];
13
+
14
+ afterEach(() => {
15
+ for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true });
16
+ delete process.env[READY_FILE_ENV];
17
+ });
18
+
19
+ /** Hands its readiness setter to the test, so a frame can be rendered before and after the flip. */
20
+ function ReadyProbe(props: {
21
+ initiallyReady: boolean;
22
+ onReady: () => void;
23
+ exposeSetter?: (setReady: (ready: boolean) => void) => void;
24
+ }) {
25
+ const [ready, setReady] = useState(props.initiallyReady);
26
+
27
+ props.exposeSetter?.(setReady);
28
+ useReadySignal(ready, props.onReady);
29
+
30
+ return createElement("text", null, ready ? "ready" : "booting");
31
+ }
32
+
33
+ describe("useReadySignal", () => {
34
+ test("fires once on the first frame after ready turns true", async () => {
35
+ // Arrange - not ready yet
36
+ let fired = 0;
37
+ let setReady: ((ready: boolean) => void) | null = null;
38
+ const onReady = () => {
39
+ fired += 1;
40
+ };
41
+ const setup = await testRender(
42
+ createElement(ReadyProbe, {
43
+ initiallyReady: false,
44
+ onReady,
45
+ exposeSetter: (setter) => {
46
+ setReady = setter;
47
+ },
48
+ }),
49
+ { width: 20, height: 3 },
50
+ );
51
+
52
+ await settle(setup);
53
+ expect(fired).toBe(0);
54
+
55
+ // Act - ready, then let the commit, the effect, and more frames than one signal needs run
56
+ setReady!(true);
57
+ await settle(setup);
58
+ await settle(setup);
59
+
60
+ // Assert - exactly one signal, and the screen shows the ready tree
61
+ expect(fired).toBe(1);
62
+ expect(setup.captureCharFrame()).toContain("ready");
63
+ setup.renderer.destroy();
64
+ });
65
+
66
+ test("writes the ready file through the env channel", async () => {
67
+ // Arrange
68
+ const home = mkdtempSync(join(tmpdir(), "cueloop-ready-"));
69
+ const readyFile = join(home, "ready");
70
+
71
+ homes.push(home);
72
+ process.env[READY_FILE_ENV] = readyFile;
73
+ const setup = await testRender(
74
+ createElement(ReadyProbe, { initiallyReady: true, onReady: () => {} }),
75
+ { width: 20, height: 3 },
76
+ );
77
+
78
+ // Act
79
+ await settle(setup);
80
+
81
+ // Assert
82
+ expect(existsSync(readyFile)).toBe(true);
83
+ setup.renderer.destroy();
84
+ });
85
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The app ready signal: fired once, after the first frame that paints a
3
+ * usable screen (a session, the no-thread shell, or an error) has been
4
+ * flushed. React runs child effects before parent effects, so by the time the
5
+ * App-level effect arms the frame listener every keyboard handler of that
6
+ * first screen is already subscribed - a key sent after the signal cannot be
7
+ * dropped. Surfaces that mount later (a composer, a lazily loaded tab) still
8
+ * need their own screen predicate. Tests wait on this instead of guessing
9
+ * from output silence. Two channels: an
10
+ * `onReady` callback for in-process suites, and the file named by
11
+ * CUELOOP_READY_FILE for subprocess and PTY suites. Users never see either.
12
+ */
13
+
14
+ import { writeFileSync } from "node:fs";
15
+ import { useEffect, useRef } from "react";
16
+ import { useRenderer } from "@opentui/react";
17
+
18
+ /** The env var naming the file the app writes once it is ready; unset in normal use. */
19
+ export const READY_FILE_ENV = "CUELOOP_READY_FILE";
20
+
21
+ /** Write the ready file when CUELOOP_READY_FILE names one; a no-op otherwise, so the signal costs nothing outside tests. */
22
+ function announceReady(): void {
23
+ const path = process.env[READY_FILE_ENV];
24
+
25
+ if (!path) return;
26
+ writeFileSync(path, "ready\n");
27
+ }
28
+
29
+ /**
30
+ * Fire the ready signal once, on the first rendered frame after `ready`
31
+ * turns true. Call it after every keyboard hook of the component so their
32
+ * subscriptions are in place when the signal goes out.
33
+ */
34
+ export function useReadySignal(ready: boolean, onReady?: () => void): void {
35
+ const renderer = useRenderer();
36
+ const fired = useRef(false);
37
+
38
+ useEffect(() => {
39
+ if (!ready || fired.current) return;
40
+ const emit = (): void => {
41
+ fired.current = true;
42
+ announceReady();
43
+ onReady?.();
44
+ };
45
+
46
+ renderer?.once("frame", emit);
47
+
48
+ return () => {
49
+ renderer?.off("frame", emit);
50
+ };
51
+ }, [ready, renderer, onReady]);
52
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * A non-diff thread (here a plan) still renders a real diff in the Changes view:
3
+ * the controller eagerly captures the live working-tree diff, so repoChanges()
4
+ * carries file contents and rows() derives from the patch - the same shape a
5
+ * cueloop diff pins, just computed live.
6
+ */
7
+
8
+ import { describe, expect, mock, test } from "bun:test";
9
+ import { SCHEMA_VERSION, type Annotation, type ReviewSession } from "@cueloop/schema";
10
+ import type { SessionClient } from "@cueloop/daemon/client";
11
+ import { createReviewController, type ShareTransport } from "./session-controller";
12
+ import { mergeFromShare } from "./share";
13
+
14
+ const AT = "2026-01-01T00:00:00.000Z";
15
+
16
+ const PATCH = `diff --git a/src/x.ts b/src/x.ts
17
+ --- a/src/x.ts
18
+ +++ b/src/x.ts
19
+ @@ -1,1 +1,1 @@
20
+ -const a = 1;
21
+ +const a = 2;
22
+ `;
23
+
24
+ const FILES = [
25
+ {
26
+ path: "src/x.ts",
27
+ oldContents: "const a = 1;\n",
28
+ newContents: "const a = 2;\n",
29
+ status: "modified" as const,
30
+ },
31
+ ];
32
+
33
+ const shareTransport: ShareTransport = {
34
+ publish: mock(async (session: ReviewSession) => ({
35
+ line: `ssh p_${session.id}@cueloop.dev`,
36
+ copied: true,
37
+ })),
38
+ pull: mock(async () => planSession()),
39
+ push: mock(async () => {}),
40
+ watch: () => () => {},
41
+ parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
42
+ collaboratorAnnotations: (session) => session.annotations.filter((entry) => entry.author),
43
+ mergeFromShare,
44
+ };
45
+
46
+ function planSession(id = "ses_plan", repoRoot = "/repo"): ReviewSession {
47
+ return {
48
+ schemaVersion: SCHEMA_VERSION,
49
+ id,
50
+ workspace: { repoRoot, branch: "main" },
51
+ artifact: { type: "plan", content: "# Plan\n", meta: {} },
52
+ revisions: [{ revision: 1, content: "# Plan\n", submittedAt: AT }],
53
+ annotations: [],
54
+ verdict: null,
55
+ status: "pending",
56
+ createdAt: AT,
57
+ };
58
+ }
59
+
60
+ const unimplemented = (member: string) => () =>
61
+ Promise.reject(new Error(`fakeClient does not implement ${member}`));
62
+
63
+ function fakeClient(session: ReviewSession): SessionClient {
64
+ return {
65
+ onEvent: () => () => {},
66
+ subscribe: async () => {},
67
+ sessionGet: async () => session,
68
+ sessionList: async () => [session],
69
+ sessionAnnotate: unimplemented("sessionAnnotate"),
70
+ sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
71
+ sessionSetWorkingCopy: unimplemented("sessionSetWorkingCopy"),
72
+ sessionCutBlock: unimplemented("sessionCutBlock"),
73
+ sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
74
+ sessionCurate: unimplemented("sessionCurate"),
75
+ sessionNavigate: unimplemented("sessionNavigate"),
76
+ sessionBranch: unimplemented("sessionBranch"),
77
+ sessionSwitch: unimplemented("sessionSwitch"),
78
+ sessionLabel: unimplemented("sessionLabel"),
79
+ sessionFork: unimplemented("sessionFork"),
80
+ sessionSetViewed: unimplemented("sessionSetViewed"),
81
+ sessionSetTitle: unimplemented("sessionSetTitle"),
82
+ sessionSetShareId: unimplemented("sessionSetShareId"),
83
+ sessionMergeShared: unimplemented("sessionMergeShared"),
84
+ sessionDelete: unimplemented("sessionDelete"),
85
+ sessionSetSelfName: unimplemented("sessionSetSelfName"),
86
+ sessionResolve: unimplemented("sessionResolve"),
87
+ repoDiff: async () => ({ patch: PATCH, files: FILES }),
88
+ close: () => {},
89
+ } satisfies SessionClient;
90
+ }
91
+
92
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
93
+
94
+ describe("live working-tree diff for a non-diff thread", () => {
95
+ test("repoChanges carries file contents and rows derive from the patch", async () => {
96
+ const session = planSession();
97
+ const controller = createReviewController({
98
+ sessionId: session.id,
99
+ openClient: async () => fakeClient(session),
100
+ shareTransport,
101
+ });
102
+ controller.connect();
103
+ await tick();
104
+
105
+ // Act - the Changes navigator loads
106
+ const changes = await controller.repoChanges();
107
+
108
+ // Assert - real per-file contents, not empty stubs
109
+ expect(changes.map((file) => file.path)).toEqual(["src/x.ts"]);
110
+ expect(changes[0]!.newContents).toBe("const a = 2;\n");
111
+
112
+ // and the diff rows derive from the live patch, so an opened file tab shows a diff
113
+ expect(controller.rows().some((row) => row.file === "src/x.ts")).toBe(true);
114
+
115
+ // the live per-file contents let a non-diff thread expand a folded file too
116
+ expect(controller.canExpandFile("src/x.ts")).toBe(true);
117
+
118
+ controller.close();
119
+ });
120
+
121
+ test("annotating the Changes diff stamps a file target anchored to the diff rows", async () => {
122
+ const session = planSession();
123
+ let sent: Annotation | undefined;
124
+ const client = {
125
+ ...fakeClient(session),
126
+ sessionAnnotate: async (_id: string, annotation: Annotation) => {
127
+ sent = annotation;
128
+
129
+ return { ...session, annotations: [{ ...annotation, createdAt: AT }] };
130
+ },
131
+ } satisfies SessionClient;
132
+ const controller = createReviewController({
133
+ sessionId: session.id,
134
+ openClient: async () => client,
135
+ shareTransport,
136
+ });
137
+ controller.connect();
138
+ await tick();
139
+ await controller.repoChanges();
140
+
141
+ // Act - comment the added line of the working-tree diff, as the Changes surface does
142
+ const rows = controller.rows();
143
+ const addIndex = rows.findIndex((row) => row.kind === "add");
144
+ const addText = rows[addIndex]!.text.replace(/\n$/, "");
145
+
146
+ controller.annotate("comment", addIndex, 0, addText.length, "eviction?", addIndex, {
147
+ kind: "file",
148
+ path: "",
149
+ rev: "worktree",
150
+ });
151
+
152
+ // Assert - the note carries the file target (path and rev filled from the row) and quotes the diff
153
+ expect(sent?.target).toEqual({ kind: "file", path: "src/x.ts", rev: "worktree" });
154
+ expect(sent?.anchor.quote).toContain("const a = 2");
155
+
156
+ controller.close();
157
+ });
158
+
159
+ test("a late diff response from a previous thread never overwrites the active one", async () => {
160
+ const sessionA = planSession("ses_A", "/repoA");
161
+ const sessionB = planSession("ses_B", "/repoB");
162
+ let resolveA: (diff: { patch: string; files: typeof FILES }) => void = () => {};
163
+ const pendingA = new Promise<{ patch: string; files: typeof FILES }>((resolve) => {
164
+ resolveA = resolve;
165
+ });
166
+ const diffB = {
167
+ patch: PATCH.replace(/x\.ts/g, "y.ts"),
168
+ files: [{ ...FILES[0]!, path: "src/y.ts" }],
169
+ };
170
+ const client = {
171
+ ...fakeClient(sessionA),
172
+ sessionList: async () => [sessionA, sessionB],
173
+ repoDiff: async (cwd: string) => (cwd === "/repoA" ? pendingA : diffB),
174
+ } satisfies SessionClient;
175
+
176
+ const controller = createReviewController({
177
+ sessionId: sessionA.id,
178
+ openClient: async () => client,
179
+ shareTransport,
180
+ });
181
+ controller.connect();
182
+ await tick();
183
+
184
+ // thread A's diff is still in flight when the user switches to thread B
185
+ const inFlightA = controller.repoChanges();
186
+ controller.open("ses_B");
187
+ await controller.repoChanges();
188
+ // B's diff is showing; now A's stale response lands
189
+ resolveA({ patch: PATCH, files: FILES });
190
+ await inFlightA;
191
+
192
+ // the active thread keeps B's changes; A's late response is dropped
193
+ expect(controller.rows().some((row) => row.file === "src/y.ts")).toBe(true);
194
+ expect(controller.rows().some((row) => row.file === "src/x.ts")).toBe(false);
195
+
196
+ controller.close();
197
+ });
198
+ });