@cueloop/client 0.1.0-alpha.73 → 0.1.0-alpha.74
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 +5 -5
- package/src/App.tsx +35 -170
- package/src/App.workbench.test.tsx +103 -2
- package/src/app-screens.tsx +84 -10
- package/src/components/AppShell.brand.test.tsx +58 -0
- package/src/components/AppShell.tsx +32 -6
- package/src/components/BareWorkbenchFileView.tsx +1 -1
- package/src/components/ConfirmCard.tsx +0 -1
- package/src/components/GridTabContent.stories.tsx +59 -0
- package/src/components/GridTabContent.tsx +165 -0
- package/src/components/SettingsDialog.tsx +4 -13
- package/src/components/ThreadTree.tsx +6 -18
- package/src/components/__snapshots__/stories.test.tsx.snap +112 -68
- package/src/components/primitives/Card.tsx +1 -1
- package/src/components/primitives/frame.test.ts +5 -5
- package/src/components/primitives/frame.ts +5 -7
- package/src/components/quick-actions-editor.stories.tsx +2 -0
- package/src/components/quick-actions-editor.test.tsx +36 -2
- package/src/components/quick-actions-editor.tsx +104 -29
- package/src/components/truncate-title.test.ts +31 -0
- package/src/components/truncate-title.ts +15 -0
- package/src/config.test.ts +36 -0
- package/src/config.ts +38 -8
- package/src/index.ts +2 -0
- package/src/intent-dispatch.test.ts +1 -0
- package/src/launch-layout.test.ts +52 -0
- package/src/launch-layout.ts +42 -0
- package/src/run.ts +10 -0
- package/src/serve.ts +31 -0
- package/src/thread-controller.live-diff.test.ts +119 -0
- package/src/thread-controller.ts +88 -27
- package/src/use-changes-workbench.ts +41 -4
- package/src/use-remember-layout.ts +31 -0
- package/src/use-settings-dialog.tsx +11 -2
- package/src/view-diff.test.ts +73 -2
- package/src/view-diff.ts +38 -0
- package/src/workbench-snapshot.test.ts +81 -0
- package/src/workbench-snapshot.ts +29 -0
- package/src/components/fade-title.test.ts +0 -49
- package/src/components/fade-title.ts +0 -61
|
@@ -197,3 +197,122 @@ describe("live working-tree diff for a non-diff thread", () => {
|
|
|
197
197
|
controller.close();
|
|
198
198
|
});
|
|
199
199
|
});
|
|
200
|
+
|
|
201
|
+
const STALE_PATCH = `diff --git a/src/stale.ts b/src/stale.ts
|
|
202
|
+
--- a/src/stale.ts
|
|
203
|
+
+++ b/src/stale.ts
|
|
204
|
+
@@ -1,1 +1,1 @@
|
|
205
|
+
-const s = 1;
|
|
206
|
+
+const s = 2;
|
|
207
|
+
`;
|
|
208
|
+
|
|
209
|
+
const STALE_FILES = [
|
|
210
|
+
{
|
|
211
|
+
path: "src/stale.ts",
|
|
212
|
+
oldContents: "const s = 1;\n",
|
|
213
|
+
newContents: "const s = 2;\n",
|
|
214
|
+
status: "modified" as const,
|
|
215
|
+
},
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
/** A `type:"diff"` thread with a captured snapshot; `meta.workbench` decides frozen vs live. */
|
|
219
|
+
function diffSession(meta: Thread["artifact"]["meta"], id = "ses_diff"): Thread {
|
|
220
|
+
return {
|
|
221
|
+
schemaVersion: SCHEMA_VERSION,
|
|
222
|
+
id,
|
|
223
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
224
|
+
artifact: { type: "diff", content: STALE_PATCH, files: STALE_FILES, meta },
|
|
225
|
+
revisions: [{ revision: 1, content: STALE_PATCH, submittedAt: AT }],
|
|
226
|
+
annotations: [],
|
|
227
|
+
verdict: null,
|
|
228
|
+
status: "pending",
|
|
229
|
+
createdAt: AT,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
describe("frozen vs live diff by thread kind", () => {
|
|
234
|
+
test("a workbench thread renders the live working tree, not its captured snapshot", async () => {
|
|
235
|
+
const session = diffSession({ workbench: true, title: "Workbench" });
|
|
236
|
+
const controller = createReviewController({
|
|
237
|
+
sessionId: session.id,
|
|
238
|
+
openClient: async () => fakeClient(session),
|
|
239
|
+
shareTransport,
|
|
240
|
+
});
|
|
241
|
+
controller.connect();
|
|
242
|
+
await tick();
|
|
243
|
+
|
|
244
|
+
// the live repoDiff (src/x.ts) wins over the frozen capture (src/stale.ts)
|
|
245
|
+
const changes = await controller.repoChanges();
|
|
246
|
+
|
|
247
|
+
expect(changes.map((file) => file.path)).toEqual(["src/x.ts"]);
|
|
248
|
+
expect(controller.rows().some((row) => row.file === "src/x.ts")).toBe(true);
|
|
249
|
+
expect(controller.rows().some((row) => row.file === "src/stale.ts")).toBe(false);
|
|
250
|
+
|
|
251
|
+
controller.close();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("a plain diff review pins its captured snapshot and never queries the live tree", async () => {
|
|
255
|
+
const session = diffSession({});
|
|
256
|
+
const repoDiff = mock(async () => ({ patch: PATCH, files: FILES }));
|
|
257
|
+
const client = { ...fakeClient(session), repoDiff } satisfies SessionClient;
|
|
258
|
+
const controller = createReviewController({
|
|
259
|
+
sessionId: session.id,
|
|
260
|
+
openClient: async () => client,
|
|
261
|
+
shareTransport,
|
|
262
|
+
});
|
|
263
|
+
controller.connect();
|
|
264
|
+
await tick();
|
|
265
|
+
|
|
266
|
+
const changes = await controller.repoChanges();
|
|
267
|
+
|
|
268
|
+
expect(changes.map((file) => file.path)).toEqual(["src/stale.ts"]);
|
|
269
|
+
expect(controller.rows().some((row) => row.file === "src/stale.ts")).toBe(true);
|
|
270
|
+
expect(repoDiff).not.toHaveBeenCalled();
|
|
271
|
+
|
|
272
|
+
controller.close();
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const SERVED_PATCH = `diff --git a/src/served.ts b/src/served.ts
|
|
277
|
+
--- a/src/served.ts
|
|
278
|
+
+++ b/src/served.ts
|
|
279
|
+
@@ -1,1 +1,1 @@
|
|
280
|
+
-const served = 0;
|
|
281
|
+
+const served = 1;
|
|
282
|
+
`;
|
|
283
|
+
|
|
284
|
+
describe("serve mode pins the served thread to a frozen snapshot", () => {
|
|
285
|
+
test("the served artifact wins over the thread's own capture and the live tree", async () => {
|
|
286
|
+
const session = diffSession({ workbench: true });
|
|
287
|
+
const repoDiff = mock(async () => ({ patch: PATCH, files: FILES }));
|
|
288
|
+
const client = { ...fakeClient(session), repoDiff } satisfies SessionClient;
|
|
289
|
+
const controller = createReviewController({
|
|
290
|
+
sessionId: session.id,
|
|
291
|
+
openClient: async () => client,
|
|
292
|
+
shareTransport,
|
|
293
|
+
servedArtifact: {
|
|
294
|
+
type: "diff",
|
|
295
|
+
content: SERVED_PATCH,
|
|
296
|
+
files: [
|
|
297
|
+
{
|
|
298
|
+
path: "src/served.ts",
|
|
299
|
+
oldContents: "const served = 0;\n",
|
|
300
|
+
newContents: "const served = 1;\n",
|
|
301
|
+
status: "modified",
|
|
302
|
+
},
|
|
303
|
+
],
|
|
304
|
+
meta: { workbench: true, snapshot: true },
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
controller.connect();
|
|
308
|
+
await tick();
|
|
309
|
+
|
|
310
|
+
// the observer sees the frozen served snapshot, not the thread's stale capture nor the live tree
|
|
311
|
+
expect(controller.rows().some((row) => row.file === "src/served.ts")).toBe(true);
|
|
312
|
+
expect(controller.rows().some((row) => row.file === "src/stale.ts")).toBe(false);
|
|
313
|
+
expect(controller.rows().some((row) => row.file === "src/x.ts")).toBe(false);
|
|
314
|
+
expect(repoDiff).not.toHaveBeenCalled();
|
|
315
|
+
|
|
316
|
+
controller.close();
|
|
317
|
+
});
|
|
318
|
+
});
|
package/src/thread-controller.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type Anchor,
|
|
30
30
|
type Annotation,
|
|
31
31
|
type AnnotationTarget,
|
|
32
|
+
type Artifact,
|
|
32
33
|
type DiffFileContents,
|
|
33
34
|
type Thread,
|
|
34
35
|
type SessionHistory,
|
|
@@ -46,8 +47,16 @@ import {
|
|
|
46
47
|
} from "./share";
|
|
47
48
|
import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view-plan";
|
|
48
49
|
import { entryTarget, treeRows, type TreeRow } from "./tree-view";
|
|
49
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
diffRowBlocks,
|
|
52
|
+
diffRows,
|
|
53
|
+
fileChangeCounts,
|
|
54
|
+
fileRowRange,
|
|
55
|
+
readsFrozenDiff,
|
|
56
|
+
type DiffRow,
|
|
57
|
+
} from "./view-diff";
|
|
50
58
|
import { applyFold } from "./diff-fold";
|
|
59
|
+
import { snapshotWorkbench } from "./workbench-snapshot";
|
|
51
60
|
import { copyToClipboard } from "./clipboard";
|
|
52
61
|
import {
|
|
53
62
|
changeRejectionForRow,
|
|
@@ -180,6 +189,8 @@ export interface ReviewControllerOptions {
|
|
|
180
189
|
*/
|
|
181
190
|
openClient?: () => Promise<SessionClient>;
|
|
182
191
|
shareTransport?: ShareTransport;
|
|
192
|
+
/** Serve mode: pin this frozen diff onto the served thread so an observer sees a stable snapshot. */
|
|
193
|
+
servedArtifact?: Artifact;
|
|
183
194
|
}
|
|
184
195
|
|
|
185
196
|
export interface ReviewController {
|
|
@@ -268,6 +279,14 @@ export interface ReviewController {
|
|
|
268
279
|
* first write a browse-only launch makes - nothing is on disk until it runs.
|
|
269
280
|
*/
|
|
270
281
|
commentOnWorkbench(anchor: Anchor, target: AnnotationTarget, body: string): Promise<void>;
|
|
282
|
+
/** As commentOnWorkbench, for a note drawn on the bare-launch Changes diff (row coords, file target). */
|
|
283
|
+
commentOnWorkbenchDiff(
|
|
284
|
+
displayIndex: number,
|
|
285
|
+
start: number,
|
|
286
|
+
end: number,
|
|
287
|
+
endDisplayIndex: number,
|
|
288
|
+
body: string,
|
|
289
|
+
): Promise<void>;
|
|
271
290
|
/**
|
|
272
291
|
* Reply to `rootAnnotationId`: the reply shares the root's anchor and names
|
|
273
292
|
* it in replyTo, so the discussion stays one conversation. Returns the minted id.
|
|
@@ -395,10 +414,19 @@ class Controller implements ReviewController {
|
|
|
395
414
|
getSnapshot = (): ControllerSnapshot => this.snapshot;
|
|
396
415
|
|
|
397
416
|
private update(patch: Partial<ControllerSnapshot>): void {
|
|
398
|
-
this.snapshot = { ...this.snapshot, ...patch };
|
|
417
|
+
this.snapshot = { ...this.snapshot, ...this.freezeServed(patch) };
|
|
399
418
|
for (const listener of this.listeners) listener();
|
|
400
419
|
}
|
|
401
420
|
|
|
421
|
+
/** Serve mode pins the served thread's diff to a snapshot; its annotations and the rest stay live. */
|
|
422
|
+
private freezeServed(patch: Partial<ControllerSnapshot>): Partial<ControllerSnapshot> {
|
|
423
|
+
const served = this.options.servedArtifact;
|
|
424
|
+
|
|
425
|
+
if (!served || !patch.session || patch.session.id !== this.options.sessionId) return patch;
|
|
426
|
+
|
|
427
|
+
return { ...patch, session: { ...patch.session, artifact: served } };
|
|
428
|
+
}
|
|
429
|
+
|
|
402
430
|
connect(): void {
|
|
403
431
|
void (async () => {
|
|
404
432
|
try {
|
|
@@ -470,9 +498,9 @@ class Controller implements ReviewController {
|
|
|
470
498
|
// ── derived projections ─────────────────────
|
|
471
499
|
private ensureDerived(): void {
|
|
472
500
|
const session = this.snapshot.session;
|
|
473
|
-
const
|
|
474
|
-
// a diff thread reads its pinned capture; every other thread
|
|
475
|
-
const liveDiff =
|
|
501
|
+
const frozenDiff = readsFrozenDiff(session);
|
|
502
|
+
// a plain diff thread reads its pinned capture; a workbench thread and every other thread reflect the live working tree
|
|
503
|
+
const liveDiff = frozenDiff ? null : this.liveDiff;
|
|
476
504
|
|
|
477
505
|
if (this.derivedFor === session && this.derivedForLiveDiff === liveDiff) return;
|
|
478
506
|
// fold state belongs to the session, so a live-tree refresh keeps it; only a new session resets it
|
|
@@ -483,8 +511,8 @@ class Controller implements ReviewController {
|
|
|
483
511
|
this.derivedFor = session;
|
|
484
512
|
this.derivedForLiveDiff = liveDiff;
|
|
485
513
|
|
|
486
|
-
const content =
|
|
487
|
-
const files =
|
|
514
|
+
const content = frozenDiff ? session!.artifact.content : (liveDiff?.patch ?? "");
|
|
515
|
+
const files = frozenDiff ? (session?.artifact.files ?? []) : (liveDiff?.files ?? []);
|
|
488
516
|
const rows = content ? diffRows(content) : [];
|
|
489
517
|
const models = new Map<string, FileDiffMetadata>();
|
|
490
518
|
|
|
@@ -502,7 +530,7 @@ class Controller implements ReviewController {
|
|
|
502
530
|
private foldFiles(): readonly DiffFileContents[] | undefined {
|
|
503
531
|
const session = this.snapshot.session;
|
|
504
532
|
|
|
505
|
-
return session
|
|
533
|
+
return readsFrozenDiff(session) ? session!.artifact.files : this.liveDiff?.files;
|
|
506
534
|
}
|
|
507
535
|
|
|
508
536
|
treeRows(): TreeRow[] {
|
|
@@ -614,9 +642,9 @@ class Controller implements ReviewController {
|
|
|
614
642
|
}
|
|
615
643
|
|
|
616
644
|
async repoChanges(): Promise<readonly DiffFileContents[]> {
|
|
617
|
-
// a diff review pins its captured snapshot; every other thread
|
|
645
|
+
// a plain diff review pins its captured snapshot; a workbench thread and every other thread reflect the live working tree
|
|
618
646
|
const session = this.snapshot.session;
|
|
619
|
-
if (session
|
|
647
|
+
if (readsFrozenDiff(session)) return session!.artifact.files ?? [];
|
|
620
648
|
// eager: capture the live working-tree diff so the Changes navigator and its file tabs render a real diff
|
|
621
649
|
if (this.client?.repoDiff !== undefined) {
|
|
622
650
|
const diff = await this.client.repoDiff(this.sidebarRepoRoot());
|
|
@@ -1073,26 +1101,49 @@ class Controller implements ReviewController {
|
|
|
1073
1101
|
return wire.id;
|
|
1074
1102
|
}
|
|
1075
1103
|
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1104
|
+
/** Find-or-create the per-repo workbench thread and adopt it as active; true once a session exists. */
|
|
1105
|
+
private async ensureWorkbenchSession(): Promise<boolean> {
|
|
1106
|
+
if (this.snapshot.session) return true;
|
|
1107
|
+
if (this.client?.sessionWorkbench === undefined) return false;
|
|
1108
|
+
let workbench;
|
|
1080
1109
|
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1110
|
+
try {
|
|
1111
|
+
workbench = await this.client.sessionWorkbench(this.options.cwd ?? process.cwd());
|
|
1112
|
+
} catch (cause) {
|
|
1113
|
+
// the composer already closed, so a lost first comment must at least surface, not vanish
|
|
1114
|
+
this.setStatus(String(cause instanceof Error ? cause.message : cause));
|
|
1086
1115
|
|
|
1087
|
-
|
|
1088
|
-
}
|
|
1089
|
-
// adopt the thread as active (the shell flips to the thread view) and let the inbox catch up
|
|
1090
|
-
this.update({ session: workbench });
|
|
1091
|
-
void this.refreshInbox();
|
|
1116
|
+
return false;
|
|
1092
1117
|
}
|
|
1118
|
+
// adopt the thread as active (the shell flips to the thread view) and let the inbox catch up
|
|
1119
|
+
this.update({ session: workbench });
|
|
1120
|
+
void this.refreshInbox();
|
|
1121
|
+
|
|
1122
|
+
return true;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
async commentOnWorkbench(anchor: Anchor, target: AnnotationTarget, body: string): Promise<void> {
|
|
1126
|
+
if (!(await this.ensureWorkbenchSession())) return;
|
|
1093
1127
|
this.addComment(anchor, target, body);
|
|
1094
1128
|
}
|
|
1095
1129
|
|
|
1130
|
+
/** A first comment on the bare-launch Changes diff: promote to the workbench thread, then anchor it in the diff rows. */
|
|
1131
|
+
async commentOnWorkbenchDiff(
|
|
1132
|
+
displayIndex: number,
|
|
1133
|
+
start: number,
|
|
1134
|
+
end: number,
|
|
1135
|
+
endDisplayIndex: number,
|
|
1136
|
+
body: string,
|
|
1137
|
+
): Promise<void> {
|
|
1138
|
+
if (!(await this.ensureWorkbenchSession())) return;
|
|
1139
|
+
// annotate re-derives the file path and rev from the diff row it lands on
|
|
1140
|
+
this.annotate("comment", displayIndex, start, end, body, endDisplayIndex, {
|
|
1141
|
+
kind: "file",
|
|
1142
|
+
path: "",
|
|
1143
|
+
rev: "worktree",
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1096
1147
|
reply(rootAnnotationId: string, body: string): string | undefined {
|
|
1097
1148
|
const session = this.snapshot.session;
|
|
1098
1149
|
const root = session?.annotations.find((annotation) => annotation.id === rootAnnotationId);
|
|
@@ -1270,13 +1321,22 @@ class Controller implements ReviewController {
|
|
|
1270
1321
|
);
|
|
1271
1322
|
}
|
|
1272
1323
|
|
|
1324
|
+
/** A workbench thread renders live locally; freeze its diff so a remote reviewer gets a stable snapshot. */
|
|
1325
|
+
private freezeForShare(session: Thread): Promise<Thread> {
|
|
1326
|
+
const repoDiff = this.client?.repoDiff;
|
|
1327
|
+
|
|
1328
|
+
return repoDiff
|
|
1329
|
+
? snapshotWorkbench(session, (root) => repoDiff.call(this.client, root))
|
|
1330
|
+
: Promise.resolve(session);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1273
1333
|
share(): void {
|
|
1274
1334
|
const session = this.snapshot.session;
|
|
1275
1335
|
|
|
1276
1336
|
if (!session) return;
|
|
1277
1337
|
this.setStatus("sharing…");
|
|
1278
|
-
this.
|
|
1279
|
-
.publish(
|
|
1338
|
+
this.freezeForShare(session)
|
|
1339
|
+
.then((shared) => this.shareTransport.publish(shared))
|
|
1280
1340
|
.then(async ({ line, copied }) => {
|
|
1281
1341
|
// Stamp the id back so a later pull knows which share to collect from.
|
|
1282
1342
|
const shareId = this.shareTransport.parseShareId(line);
|
|
@@ -1373,7 +1433,8 @@ class Controller implements ReviewController {
|
|
|
1373
1433
|
client
|
|
1374
1434
|
.sessionFork(session.id)
|
|
1375
1435
|
.then(async (fork) => {
|
|
1376
|
-
const
|
|
1436
|
+
const shared = await this.freezeForShare(fork);
|
|
1437
|
+
const { line, copied } = await this.shareTransport.publish(shared);
|
|
1377
1438
|
const shareId = this.shareTransport.parseShareId(line);
|
|
1378
1439
|
|
|
1379
1440
|
if (shareId) await client.sessionSetShareId(fork.id, shareId);
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { useRef, useState } from "react";
|
|
6
6
|
import type { ProjectPanelMode } from "./components/AppShell";
|
|
7
|
+
import type { LaunchLayout } from "./launch-layout";
|
|
7
8
|
import {
|
|
8
9
|
activateTab,
|
|
9
10
|
addTab,
|
|
@@ -22,6 +23,41 @@ import {
|
|
|
22
23
|
/** Seed the workbench for a bare launch: the region opens on a disposable Welcome tab. */
|
|
23
24
|
export interface ChangesWorkbenchOptions {
|
|
24
25
|
seed?: "changes" | "welcome";
|
|
26
|
+
/** The launch layout the right region opens in; overrides the seed's defaults. */
|
|
27
|
+
layout?: LaunchLayout;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface InitialPanes {
|
|
31
|
+
projectOpen: boolean;
|
|
32
|
+
changesOpen: boolean;
|
|
33
|
+
projectMode: ProjectPanelMode;
|
|
34
|
+
zoomed: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The right region's opening state: from the launch layout when given, else the welcome-seed defaults. */
|
|
38
|
+
function initialPanes(options?: ChangesWorkbenchOptions): InitialPanes {
|
|
39
|
+
const layout = options?.layout;
|
|
40
|
+
|
|
41
|
+
if (layout) {
|
|
42
|
+
const changesOpen = layout.rightSidebar === "changes";
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
projectOpen: layout.rightSidebar !== "off",
|
|
46
|
+
changesOpen,
|
|
47
|
+
projectMode: layout.rightSidebar === "project" ? "tree" : "changes",
|
|
48
|
+
// zoom fills the middle with the Changes editor, so a hand-edited layout that zooms without one
|
|
49
|
+
// would strand a blank center with the Thread pane hidden; only honor zoom when Changes is open
|
|
50
|
+
zoomed: layout.zoomChanges && changesOpen,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const welcomeSeed = options?.seed === "welcome";
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
projectOpen: welcomeSeed,
|
|
57
|
+
changesOpen: welcomeSeed,
|
|
58
|
+
projectMode: "changes",
|
|
59
|
+
zoomed: false,
|
|
60
|
+
};
|
|
25
61
|
}
|
|
26
62
|
|
|
27
63
|
export interface ChangesWorkbench {
|
|
@@ -46,10 +82,11 @@ export interface ChangesWorkbench {
|
|
|
46
82
|
|
|
47
83
|
export function useChangesWorkbench(options?: ChangesWorkbenchOptions): ChangesWorkbench {
|
|
48
84
|
const welcomeSeed = options?.seed === "welcome";
|
|
49
|
-
const
|
|
50
|
-
const [
|
|
51
|
-
const [
|
|
52
|
-
const [
|
|
85
|
+
const initial = initialPanes(options);
|
|
86
|
+
const [projectOpen, setProjectOpen] = useState(initial.projectOpen);
|
|
87
|
+
const [changesOpen, setChangesOpen] = useState(initial.changesOpen);
|
|
88
|
+
const [projectMode, setProjectMode] = useState<ProjectPanelMode>(initial.projectMode);
|
|
89
|
+
const [zoomed, setZoomed] = useState(initial.zoomed);
|
|
53
90
|
const [grid, setGrid] = useState<EditorNode>(() =>
|
|
54
91
|
makeGroup([welcomeSeed ? welcomeTab() : changesTab()]),
|
|
55
92
|
);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { persistLayout } from "./config";
|
|
3
|
+
import { layoutFromPanes, type LaunchLayout } from "./launch-layout";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Persist the pane composition whenever the user changes it, so the next bare launch restores it.
|
|
7
|
+
* Only a launch that carried an intended layout (a create-command or the vanilla inbox) remembers;
|
|
8
|
+
* a bare thread open collapses the sidebar and must not clobber the remembered composition. The
|
|
9
|
+
* mount write is skipped: the opening composition is the one just restored.
|
|
10
|
+
*/
|
|
11
|
+
export function useRememberLayout(
|
|
12
|
+
layout: LaunchLayout | undefined,
|
|
13
|
+
active: boolean,
|
|
14
|
+
threads: boolean,
|
|
15
|
+
changesOpen: boolean,
|
|
16
|
+
projectOpen: boolean,
|
|
17
|
+
zoomChanges: boolean,
|
|
18
|
+
): void {
|
|
19
|
+
const settled = useRef(false);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!settled.current) {
|
|
23
|
+
settled.current = true;
|
|
24
|
+
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
// only the active workbench persists: on a bare launch App's unused panes must not clobber the shell's
|
|
28
|
+
if (!active || layout === undefined) return;
|
|
29
|
+
persistLayout(layoutFromPanes({ threads, changesOpen, projectOpen, zoomChanges }));
|
|
30
|
+
}, [active, layout, threads, changesOpen, projectOpen, zoomChanges]);
|
|
31
|
+
}
|
|
@@ -111,6 +111,15 @@ export function useSettingsDialog(params: {
|
|
|
111
111
|
setQuickActions(next);
|
|
112
112
|
persistActions(next);
|
|
113
113
|
};
|
|
114
|
+
const editActionPrompt = (index: number, prompt: string): void => {
|
|
115
|
+
// a blank title fails the config schema and would vanish on reload, so never persist one
|
|
116
|
+
if (prompt.trim().length === 0) return;
|
|
117
|
+
commitActions(
|
|
118
|
+
quickActions.map((action, actionIndex) =>
|
|
119
|
+
actionIndex === index ? { ...action, prompt } : action,
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
};
|
|
114
123
|
const editActionMetadata = (index: number, metadata: string): void =>
|
|
115
124
|
commitActions(
|
|
116
125
|
quickActions.map((action, actionIndex) =>
|
|
@@ -175,6 +184,7 @@ export function useSettingsDialog(params: {
|
|
|
175
184
|
setSettingsNav((state) => ({ ...state, zone: "body", rowIndex: index }));
|
|
176
185
|
setActionsExpandedIndex((current) => (current === index ? null : index));
|
|
177
186
|
}}
|
|
187
|
+
onEditPrompt={editActionPrompt}
|
|
178
188
|
onEditMetadata={editActionMetadata}
|
|
179
189
|
onReset={resetActions}
|
|
180
190
|
onAdd={addAction}
|
|
@@ -215,8 +225,7 @@ export function useSettingsDialog(params: {
|
|
|
215
225
|
};
|
|
216
226
|
|
|
217
227
|
const onCategorySelect = (categoryId: string): void => {
|
|
218
|
-
//
|
|
219
|
-
// not become the active category, or the key handler dereferences nothing
|
|
228
|
+
// ignore a select that names no real category, so the key handler never dereferences nothing
|
|
220
229
|
if (!settingsCategories.some((category) => category.id === categoryId)) return;
|
|
221
230
|
setActionsExpandedIndex(null);
|
|
222
231
|
setSettingsNav({ categoryId, rowIndex: 0, zone: "body" });
|
package/src/view-diff.test.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { makeAnchor, type Annotation } from "@cueloop/schema";
|
|
3
|
-
import {
|
|
2
|
+
import { makeAnchor, type Annotation, type Thread } from "@cueloop/schema";
|
|
3
|
+
import {
|
|
4
|
+
changesMarks,
|
|
5
|
+
diffRowAnchor,
|
|
6
|
+
diffRowBlocks,
|
|
7
|
+
diffRows,
|
|
8
|
+
fileRowRange,
|
|
9
|
+
fileTargetMarks,
|
|
10
|
+
} from "./view-diff";
|
|
4
11
|
|
|
5
12
|
const PATCH = `diff --git a/src/store.ts b/src/store.ts
|
|
6
13
|
index 111..222 100644
|
|
@@ -110,3 +117,67 @@ diff --git a/b.ts b/b.ts
|
|
|
110
117
|
expect(marked.every((index) => index >= range.start && index < range.end)).toBe(true);
|
|
111
118
|
});
|
|
112
119
|
});
|
|
120
|
+
|
|
121
|
+
describe("changesMarks", () => {
|
|
122
|
+
const PATCH = `diff --git a/x.ts b/x.ts
|
|
123
|
+
--- a/x.ts
|
|
124
|
+
+++ b/x.ts
|
|
125
|
+
@@ -1,1 +1,2 @@
|
|
126
|
+
const keep = 0;
|
|
127
|
+
+const added = 1;
|
|
128
|
+
`;
|
|
129
|
+
|
|
130
|
+
function diffThread(meta: Thread["artifact"]["meta"], annotations: Annotation[]): Thread {
|
|
131
|
+
return {
|
|
132
|
+
schemaVersion: "1",
|
|
133
|
+
id: "s_changes",
|
|
134
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
135
|
+
artifact: { type: "diff", content: PATCH, meta },
|
|
136
|
+
revisions: [{ revision: 1, content: PATCH, submittedAt: "2026-01-01T00:00:00Z" }],
|
|
137
|
+
annotations,
|
|
138
|
+
verdict: null,
|
|
139
|
+
status: "pending",
|
|
140
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function fileNote(rows: ReturnType<typeof diffRows>): Annotation {
|
|
145
|
+
const range = fileRowRange(rows, "x.ts")!;
|
|
146
|
+
const fileRows = rows.slice(range.start, range.end);
|
|
147
|
+
const addRelative = fileRows.findIndex((row) => row.kind === "add");
|
|
148
|
+
const quote = fileRows[addRelative]!.text.replace(/\n$/, "");
|
|
149
|
+
const anchor = makeAnchor(diffRowBlocks(fileRows), addRelative, 0, quote.length, addRelative);
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
id: "f1",
|
|
153
|
+
kind: "comment",
|
|
154
|
+
anchor,
|
|
155
|
+
target: { kind: "file", path: "x.ts", rev: "worktree" },
|
|
156
|
+
body: "note",
|
|
157
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
test("a workbench thread paints its file-targeted working-tree notes", () => {
|
|
162
|
+
const rows = diffRows(PATCH);
|
|
163
|
+
const session = diffThread({ workbench: true }, [fileNote(rows)]);
|
|
164
|
+
|
|
165
|
+
expect([...changesMarks(session, rows).keys()].length).toBeGreaterThan(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("a pinned diff review paints only artifact notes, not the working tree's file targets", () => {
|
|
169
|
+
const rows = diffRows(PATCH);
|
|
170
|
+
const session = diffThread({}, [fileNote(rows)]);
|
|
171
|
+
|
|
172
|
+
// the same file-targeted note is not an artifact note, so a frozen review leaves it unpainted
|
|
173
|
+
expect([...changesMarks(session, rows).keys()].length).toBe(0);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("a shared snapshot still paints the workbench's file-targeted feedback", () => {
|
|
177
|
+
const rows = diffRows(PATCH);
|
|
178
|
+
// a served/shared snapshot keeps the workbench marker, so its notes stay file-targeted and visible
|
|
179
|
+
const session = diffThread({ workbench: true, snapshot: true }, [fileNote(rows)]);
|
|
180
|
+
|
|
181
|
+
expect([...changesMarks(session, rows).keys()].length).toBeGreaterThan(0);
|
|
182
|
+
});
|
|
183
|
+
});
|
package/src/view-diff.ts
CHANGED
|
@@ -248,6 +248,44 @@ export function fileTargetMarks(
|
|
|
248
248
|
return result;
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
+
/** A plain diff review's notes anchor to the artifact itself; a working-tree diff's notes target files. */
|
|
252
|
+
export function annotatesArtifact(session: Thread | null): boolean {
|
|
253
|
+
return session?.artifact.type === "diff" && session.artifact.meta.workbench !== true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Render the diff from the pinned artifact rather than the live working tree: a plain diff review (the
|
|
258
|
+
* artifact is the diff), or a shared/served snapshot of a workbench thread (the remote has no tree).
|
|
259
|
+
*/
|
|
260
|
+
export function readsFrozenDiff(session: Thread | null): boolean {
|
|
261
|
+
return (
|
|
262
|
+
session?.artifact.type === "diff" &&
|
|
263
|
+
(session.artifact.meta.workbench !== true || session.artifact.meta.snapshot === true)
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The marks a Changes surface paints. A plain diff review is the artifact itself, so only its
|
|
269
|
+
* artifact-anchored notes belong; a workbench thread (live or a frozen snapshot) and every other thread
|
|
270
|
+
* show a working-tree diff, whose notes carry a file target and resolve per file - so a refresh never
|
|
271
|
+
* rebinds one across files, and a snapshot still surfaces the feedback already on the thread.
|
|
272
|
+
*/
|
|
273
|
+
export function changesMarks(
|
|
274
|
+
session: Thread,
|
|
275
|
+
rows: DiffRow[],
|
|
276
|
+
focusedId?: string,
|
|
277
|
+
): Map<number, Mark[]> {
|
|
278
|
+
if (annotatesArtifact(session)) {
|
|
279
|
+
const artifactNotes = session.annotations.filter(
|
|
280
|
+
(annotation) => annotationTarget(annotation).kind === "artifact",
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
return marksByRows(artifactNotes, rows, focusedId);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return fileTargetMarks(session.annotations, rows, focusedId);
|
|
287
|
+
}
|
|
288
|
+
|
|
251
289
|
/**
|
|
252
290
|
* Comments per file path across the diff: each discussion counts its comments (root plus
|
|
253
291
|
* replies) toward the file its span ends in. Feeds the changed-files tree and tab badges, so a
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Sharing/serving a workbench thread freezes its live working-tree diff into the artifact so a remote
|
|
2
|
+
* reviewer, who cannot see the owner's tree, reads a stable snapshot; any other thread passes through. */
|
|
3
|
+
|
|
4
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
5
|
+
import { historyFromLinear, viewFollowing, type Thread } from "@cueloop/schema";
|
|
6
|
+
import { snapshotWorkbench } from "./workbench-snapshot";
|
|
7
|
+
|
|
8
|
+
const AT = "2026-01-01T00:00:00.000Z";
|
|
9
|
+
|
|
10
|
+
const STALE_FILES = [
|
|
11
|
+
{ path: "src/a.ts", oldContents: "a\n", newContents: "b\n", status: "modified" as const },
|
|
12
|
+
];
|
|
13
|
+
const LIVE = {
|
|
14
|
+
patch: "FRESH PATCH",
|
|
15
|
+
files: [
|
|
16
|
+
{ path: "src/a.ts", oldContents: "a\n", newContents: "c\n", status: "modified" as const },
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function diffThread(meta: Thread["artifact"]["meta"]): Thread {
|
|
21
|
+
return {
|
|
22
|
+
schemaVersion: "1",
|
|
23
|
+
id: "ses_wb",
|
|
24
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
25
|
+
artifact: { type: "diff", content: "STALE PATCH", files: STALE_FILES, meta },
|
|
26
|
+
revisions: [{ revision: 1, content: "STALE PATCH", submittedAt: AT }],
|
|
27
|
+
annotations: [
|
|
28
|
+
{
|
|
29
|
+
id: "n1",
|
|
30
|
+
kind: "comment",
|
|
31
|
+
anchor: { quote: "b", prefix: "", suffix: "" },
|
|
32
|
+
body: "why?",
|
|
33
|
+
createdAt: AT,
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
verdict: null,
|
|
37
|
+
status: "pending",
|
|
38
|
+
createdAt: AT,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("snapshotWorkbench", () => {
|
|
43
|
+
test("freezes a workbench thread's live diff and drops the workbench flag", async () => {
|
|
44
|
+
const frozen = await snapshotWorkbench(
|
|
45
|
+
diffThread({ workbench: true, title: "Workbench" }),
|
|
46
|
+
async () => LIVE,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// the live diff is pinned into the artifact
|
|
50
|
+
expect(frozen.artifact.content).toBe("FRESH PATCH");
|
|
51
|
+
expect(frozen.artifact.files).toEqual(LIVE.files);
|
|
52
|
+
// the snapshot marker pins the render; the workbench marker stays so file-targeted feedback still paints
|
|
53
|
+
expect(frozen.artifact.meta.snapshot).toBe(true);
|
|
54
|
+
expect(frozen.artifact.meta.workbench).toBe(true);
|
|
55
|
+
expect(frozen.artifact.meta.title).toBe("Workbench");
|
|
56
|
+
// the thread is otherwise intact - same id, workspace, and annotations
|
|
57
|
+
expect(frozen.id).toBe("ses_wb");
|
|
58
|
+
expect(frozen.annotations).toHaveLength(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("recaptures history so the share path rebuilds the fresh content, not the stale head", async () => {
|
|
62
|
+
// a workbench thread carries branch history whose head is the creation-time capture
|
|
63
|
+
const base = diffThread({ workbench: true });
|
|
64
|
+
const withHistory: Thread = { ...base, history: historyFromLinear(base) };
|
|
65
|
+
|
|
66
|
+
const frozen = await snapshotWorkbench(withHistory, async () => LIVE);
|
|
67
|
+
|
|
68
|
+
// viewFollowing (the share path) rebuilds content from history - it must yield the fresh patch
|
|
69
|
+
expect(viewFollowing(frozen).artifact.content).toBe("FRESH PATCH");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("passes a plain diff review through untouched and never queries the tree", async () => {
|
|
73
|
+
const session = diffThread({ title: "PR #7" });
|
|
74
|
+
const repoDiff = mock(async () => LIVE);
|
|
75
|
+
|
|
76
|
+
const result = await snapshotWorkbench(session, repoDiff);
|
|
77
|
+
|
|
78
|
+
expect(result).toBe(session);
|
|
79
|
+
expect(repoDiff).not.toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
});
|