@cueloop/client 0.1.0-alpha.93 → 0.1.0-alpha.94

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.93",
3
+ "version": "0.1.0-alpha.94",
4
4
  "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -27,18 +27,18 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@cueloop/daemon": "0.1.0-alpha.93",
31
- "@cueloop/extension-api": "0.1.0-alpha.93",
32
- "@cueloop/integration-obsidian": "0.1.0-alpha.93",
33
- "@cueloop/schema": "0.1.0-alpha.93",
34
- "@opentui/core": "^0.5.11",
35
- "@opentui/keymap": "0.5.11",
36
- "@opentui/react": "^0.5.11",
37
- "@opentui/ssh": "0.5.11",
30
+ "@cueloop/daemon": "0.1.0-alpha.94",
31
+ "@cueloop/extension-api": "0.1.0-alpha.94",
32
+ "@cueloop/integration-obsidian": "0.1.0-alpha.94",
33
+ "@cueloop/schema": "0.1.0-alpha.94",
34
+ "@opentui/core": "^0.5.12",
35
+ "@opentui/keymap": "0.5.12",
36
+ "@opentui/react": "^0.5.12",
37
+ "@opentui/ssh": "0.5.12",
38
38
  "@pierre/diffs": "^1.4.1",
39
39
  "@tanstack/virtual-core": "^3.17.11",
40
40
  "diff": "^9",
41
- "puppeteer-core": "^25.10.0",
41
+ "puppeteer-core": "^25.11.0",
42
42
  "react": "^19.3.0",
43
43
  "valibot": "^1.5.0",
44
44
  "zustand": "^5.0.15"
package/src/App.tsx CHANGED
@@ -112,6 +112,10 @@ const TOAST_DISMISS_MS = 2000;
112
112
  export interface AppProps {
113
113
  home?: string;
114
114
  sessionId?: string;
115
+ /** Review data already captured by the command before the UI mounts. */
116
+ initialReview?: { session: Thread; diff: { patch: string; files: DiffFileContents[] } };
117
+ /** An owner connection already opened by the launching command. */
118
+ initialClient?: ThreadClient;
115
119
  /** The launch directory whose git repo backs the no-session welcome tree; defaults to process.cwd(). */
116
120
  cwd?: string;
117
121
  /**
@@ -151,6 +155,18 @@ export interface AppProps {
151
155
  extensionRegistry?: ClientExtensionRegistry;
152
156
  }
153
157
 
158
+ function initialReviewControllerOptions(initialReview: AppProps["initialReview"]) {
159
+ const options = { initialSession: initialReview?.session, initialDiff: initialReview?.diff };
160
+
161
+ return options;
162
+ }
163
+
164
+ function initialReviewFiles(initialReview: AppProps["initialReview"], sessionId: string) {
165
+ const files = initialReview?.session.id === sessionId ? initialReview.diff.files : undefined;
166
+
167
+ return files;
168
+ }
169
+
154
170
  function extensionContext(cwd: string | undefined, threadId: string | null): ExtensionUIContext {
155
171
  return { workspace: cwd ?? process.cwd(), threadId };
156
172
  }
@@ -326,6 +342,7 @@ function authorLabelResolver(
326
342
  function ProjectPanelBody(props: {
327
343
  mode: ProjectPanelMode;
328
344
  loadChanges: () => Promise<readonly DiffFileContents[]>;
345
+ initialFiles?: readonly DiffFileContents[];
329
346
  loadProjectFiles: () => Promise<string[]>;
330
347
  diffSourceKey: string;
331
348
  liveWorkingTree: boolean;
@@ -340,6 +357,7 @@ function ProjectPanelBody(props: {
340
357
  loadChanges: props.loadChanges,
341
358
  visible: props.mode === "changes" && props.liveWorkingTree,
342
359
  diffSourceKey: props.diffSourceKey,
360
+ initialFiles: props.initialFiles,
343
361
  });
344
362
 
345
363
  if (props.mode === "changes") {
@@ -531,6 +549,8 @@ export function reconciledFocus(navigable: FocusPane[], focusedPane: FocusPane):
531
549
  export function App({
532
550
  home,
533
551
  sessionId,
552
+ initialReview,
553
+ initialClient,
534
554
  cwd,
535
555
  readOnly = false,
536
556
  onExit,
@@ -551,6 +571,8 @@ export function App({
551
571
  createReviewController({
552
572
  home,
553
573
  sessionId,
574
+ ...initialReviewControllerOptions(initialReview),
575
+ initialClient,
554
576
  cwd,
555
577
  readOnly: observer,
556
578
  onExit,
@@ -581,6 +603,11 @@ export function App({
581
603
  );
582
604
  const { session, inbox, status, toast, error, completion, editOrphanCount, walk } =
583
605
  useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
606
+ const initialReviewForFirstMount = useRef(initialReview);
607
+
608
+ useEffect(() => {
609
+ initialReviewForFirstMount.current = undefined;
610
+ }, []);
584
611
 
585
612
  // A shared plan you own polls for collaborator notes while it is open; the
586
613
  // merge refreshes through the normal event path. Stops on leave.
@@ -1446,6 +1473,10 @@ export function App({
1446
1473
  <ProjectPanelBody
1447
1474
  mode={workbench.projectMode}
1448
1475
  loadChanges={() => controller.repoChanges()}
1476
+ initialFiles={initialReviewFiles(
1477
+ initialReviewForFirstMount.current,
1478
+ activeSession.id,
1479
+ )}
1449
1480
  loadProjectFiles={() => controller.repoFiles()}
1450
1481
  diffSourceKey={activeSession.id}
1451
1482
  {...projectDiffFiles(activeSession)}
@@ -112,7 +112,89 @@ const rightToggleColumn = (setup: Setup): number => toggleColumn(setup, NERD.sid
112
112
  // the collapsed rail shows the outline (off) variant, mirroring the left toggle's filled/outline states
113
113
  const railToggleColumn = (setup: Setup): number => toggleColumn(setup, NERD.sidebarRightOff);
114
114
 
115
+ function changedFileVisibleInRightTree(frame: string, file: string): boolean {
116
+ return frame.split("\n").some((line) => {
117
+ const divider = line.lastIndexOf("│");
118
+
119
+ return divider >= 0 && line.slice(divider + 1).includes(file);
120
+ });
121
+ }
122
+
115
123
  describe("the four-pane workbench", () => {
124
+ test("a bootstrapped workbench paints its changed-file list in the first usable frame", async () => {
125
+ const workbench = server.core.sessionCreate({
126
+ workspace: { repoRoot: repo, branch: "main" },
127
+ artifact: { type: "diff", content: "", meta: { title: "Workbench", workbench: true } },
128
+ });
129
+ const initialReview = {
130
+ session: workbench,
131
+ diff: {
132
+ patch: PATCH,
133
+ files: [
134
+ {
135
+ path: "src/store.ts",
136
+ oldContents: "export class Store { private items = []; }\n",
137
+ newContents: "export class Store { private items = new Map(); }\n",
138
+ status: "modified" as const,
139
+ },
140
+ ],
141
+ },
142
+ };
143
+ const setup = await renderReadyApp(
144
+ <App
145
+ home={home}
146
+ sessionId={workbench.id}
147
+ initialReview={initialReview}
148
+ layout={reviewLayout()}
149
+ />,
150
+ { width: 160, height: 20 },
151
+ );
152
+
153
+ expect(changedFileVisibleInRightTree(setup.captureCharFrame(), "store.ts")).toBe(true);
154
+ setup.renderer.destroy();
155
+ });
156
+
157
+ test("reopening Changes does not restore files from the launch snapshot", async () => {
158
+ const workbench = server.core.sessionCreate({
159
+ workspace: { repoRoot: repo, branch: "main" },
160
+ artifact: { type: "diff", content: "", meta: { title: "Workbench", workbench: true } },
161
+ });
162
+ const initialReview = {
163
+ session: workbench,
164
+ diff: {
165
+ patch: PATCH,
166
+ files: [
167
+ {
168
+ path: "src/store.ts",
169
+ oldContents: "before\n",
170
+ newContents: "after\n",
171
+ status: "modified" as const,
172
+ },
173
+ ],
174
+ },
175
+ };
176
+ const setup = await renderReadyApp(
177
+ <App
178
+ home={home}
179
+ sessionId={workbench.id}
180
+ initialReview={initialReview}
181
+ layout={reviewLayout()}
182
+ />,
183
+ { width: 160, height: 20 },
184
+ );
185
+
186
+ await waitForState(
187
+ setup,
188
+ () => !changedFileVisibleInRightTree(setup.captureCharFrame(), "store.ts"),
189
+ );
190
+ await setup.mockMouse.click(rightToggleColumn(setup), HEADER_ROW);
191
+ await waitForState(setup, () => diffToggleColumn(setup) === -1);
192
+ await setup.mockMouse.click(railToggleColumn(setup), HEADER_ROW);
193
+
194
+ expect(changedFileVisibleInRightTree(setup.captureCharFrame(), "store.ts")).toBe(false);
195
+ setup.renderer.destroy();
196
+ });
197
+
116
198
  test("rapid wheel scrolling a large Changes diff keeps the app responsive", async () => {
117
199
  const filePatches = Array.from({ length: 40 }, (_, fileIndex) => [
118
200
  `diff --git a/file-${fileIndex}.ts b/file-${fileIndex}.ts`,
package/src/run.ts CHANGED
@@ -6,6 +6,8 @@ import { createRoot } from "@opentui/react";
6
6
  import { App } from "./App";
7
7
  import { closePrototypeBrowser } from "./prototype-browser";
8
8
  import type { Appearance } from "./theme-presets";
9
+ import type { DiffFileContents, Thread } from "@cueloop/schema";
10
+ import type { ThreadClient } from "@cueloop/daemon/client";
9
11
  import { loadConfig } from "./config";
10
12
  import { perfMark } from "./perf/perf-timings";
11
13
  import { reportPerfMarks } from "./perf/perf-report";
@@ -17,6 +19,8 @@ import {
17
19
 
18
20
  export interface RunClientOptions {
19
21
  sessionId?: string;
22
+ initialReview?: { session: Thread; diff: { patch: string; files: DiffFileContents[] } };
23
+ initialClient?: ThreadClient;
20
24
  home?: string;
21
25
  /** The layout a create-command opens in; omit to restore the remembered one, then the default. */
22
26
  layout?: LaunchLayout;
@@ -73,6 +77,8 @@ export async function runClient(options: RunClientOptions): Promise<number> {
73
77
  React.createElement(App, {
74
78
  home: options.home,
75
79
  sessionId: options.sessionId,
80
+ initialReview: options.initialReview,
81
+ initialClient: options.initialClient,
76
82
  appearance,
77
83
  layout,
78
84
  extensionRegistry,
@@ -97,6 +97,36 @@ function fakeClient(session: Thread): ThreadClient {
97
97
  const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
98
98
 
99
99
  describe("live working-tree diff for a non-diff thread", () => {
100
+ test("a preconnected workbench accepts a comment before its inbox refresh", async () => {
101
+ const session = planSession();
102
+ const sent: Annotation[] = [];
103
+ const client = {
104
+ ...fakeClient(session),
105
+ sessionComment: async (_id: string, annotation: Annotation) => {
106
+ sent.push(annotation);
107
+
108
+ return { ...session, annotations: sent };
109
+ },
110
+ } satisfies ThreadClient;
111
+ const controller = createReviewController({
112
+ sessionId: session.id,
113
+ initialSession: session,
114
+ initialDiff: { patch: PATCH, files: FILES },
115
+ initialClient: client,
116
+ shareTransport,
117
+ });
118
+
119
+ controller.addComment(
120
+ { quote: "const a = 2;", prefix: "", suffix: "" },
121
+ { kind: "file", path: "src/x.ts", rev: "worktree" },
122
+ "ready frame comment",
123
+ );
124
+
125
+ expect(sent).toHaveLength(1);
126
+ expect(sent[0]?.body).toBe("ready frame comment");
127
+ controller.close();
128
+ });
129
+
100
130
  test("repoChanges carries file contents and rows derive from the patch", async () => {
101
131
  const session = planSession();
102
132
  const controller = createReviewController({
@@ -251,6 +251,11 @@ export interface NewShareLink {
251
251
  export interface ReviewControllerOptions {
252
252
  home?: string;
253
253
  sessionId?: string;
254
+ /** Captured before the UI mounts, so the first review frame has its thread and live diff. */
255
+ initialSession?: Thread;
256
+ initialDiff?: { patch: string; files: DiffFileContents[] };
257
+ /** The owner connection that supplied the first review snapshot. */
258
+ initialClient?: ThreadClient;
254
259
  /** The directory the client launched in; its git repo backs the no-session welcome tree. Defaults to process.cwd(). */
255
260
  cwd?: string;
256
261
  /** Observer mode: stored for the key reducer's read-only gate. */
@@ -491,6 +496,7 @@ class Controller implements ReviewController {
491
496
  private derivedFor: Thread | null = null;
492
497
  /** The launch/workspace repo's live working-tree diff, feeding the Changes view for non-diff threads. */
493
498
  private liveDiff: LiveWorkingDiff | null = null;
499
+ private initialDiffAvailable = false;
494
500
  private derivedForLiveDiff: LiveWorkingDiff | null = null;
495
501
  private derived: DerivedSessionProjection = {
496
502
  display: [],
@@ -514,6 +520,12 @@ class Controller implements ReviewController {
514
520
  }
515
521
 
516
522
  constructor(private readonly options: ReviewControllerOptions) {
523
+ this.client = options.initialClient ?? null;
524
+ if (options.initialSession?.id === options.sessionId && options.initialDiff) {
525
+ this.liveDiff = options.initialDiff;
526
+ this.initialDiffAvailable = true;
527
+ this.store.setState({ session: options.initialSession });
528
+ }
517
529
  this.readOnly = options.readOnly ?? false;
518
530
  this.clock = options.clock ?? new SystemClock();
519
531
  this.shareTransport = options.shareTransport ?? DEFAULT_SHARE_TRANSPORT;
@@ -541,7 +553,9 @@ class Controller implements ReviewController {
541
553
  try {
542
554
  const openClient =
543
555
  this.options.openClient ??
544
- (() => DaemonClient.connect({ home: this.options.home, autostart: true }));
556
+ (() =>
557
+ this.options.initialClient ??
558
+ DaemonClient.connect({ home: this.options.home, autostart: true }));
545
559
  const client = await openClient();
546
560
 
547
561
  if (this.closed) return void client.close();
@@ -839,6 +853,9 @@ class Controller implements ReviewController {
839
853
  // a plain diff review pins its captured snapshot; a workbench thread and every other thread reflect the live working tree
840
854
  const session = this.snapshot.session;
841
855
  if (readsFrozenDiff(session)) return session!.artifact.files ?? [];
856
+ const initialFiles = this.consumeInitialDiffFiles(session);
857
+
858
+ if (initialFiles !== null) return initialFiles;
842
859
  // eager: capture the live working-tree diff so the Changes navigator and its file tabs render a real diff
843
860
  if (this.client?.repoDiff !== undefined) {
844
861
  const diff = await this.client.repoDiff(
@@ -879,6 +896,13 @@ class Controller implements ReviewController {
879
896
  }));
880
897
  }
881
898
 
899
+ private consumeInitialDiffFiles(session: Thread | null): readonly DiffFileContents[] | null {
900
+ if (!this.initialDiffAvailable) return null;
901
+ this.initialDiffAvailable = false;
902
+
903
+ return session?.id === this.options.sessionId ? (this.liveDiff?.files ?? []) : null;
904
+ }
905
+
882
906
  async refreshDiff(): Promise<void> {
883
907
  const session = this.snapshot.session;
884
908
 
@@ -8,6 +8,7 @@ interface RepoChangesOptions {
8
8
  loadChanges: () => Promise<readonly DiffFileContents[]>;
9
9
  visible: boolean;
10
10
  diffSourceKey: string;
11
+ initialFiles?: readonly DiffFileContents[];
11
12
  refreshAutomatically?: boolean;
12
13
  }
13
14
 
@@ -34,9 +35,12 @@ export function useRepoChanges({
34
35
  loadChanges,
35
36
  visible,
36
37
  diffSourceKey,
38
+ initialFiles,
37
39
  refreshAutomatically = true,
38
40
  }: RepoChangesOptions): readonly DiffFileContents[] {
39
- const [snapshot, setSnapshot] = useState<RepoChangesSnapshot | null>(null);
41
+ const [snapshot, setSnapshot] = useState<RepoChangesSnapshot | null>(() =>
42
+ initialFiles ? { diffSourceKey, files: initialFiles } : null,
43
+ );
40
44
  const loadLatestChanges = useEffectEvent(loadChanges);
41
45
 
42
46
  useEffect(() => {