@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 +10 -10
- package/src/App.tsx +31 -0
- package/src/App.workbench.test.tsx +82 -0
- package/src/run.ts +6 -0
- package/src/thread-controller.live-diff.test.ts +30 -0
- package/src/thread-controller.ts +25 -1
- package/src/use-repo-changes.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
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.
|
|
31
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
32
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
33
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
34
|
-
"@opentui/core": "^0.5.
|
|
35
|
-
"@opentui/keymap": "0.5.
|
|
36
|
-
"@opentui/react": "^0.5.
|
|
37
|
-
"@opentui/ssh": "0.5.
|
|
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.
|
|
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({
|
package/src/thread-controller.ts
CHANGED
|
@@ -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
|
-
(() =>
|
|
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
|
|
package/src/use-repo-changes.ts
CHANGED
|
@@ -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>(
|
|
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(() => {
|