@cueloop/client 0.1.0-alpha.92 → 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.
Files changed (40) hide show
  1. package/package.json +10 -10
  2. package/src/App.deleted-scroll.test.tsx +186 -0
  3. package/src/App.diff.test.tsx +3 -4
  4. package/src/App.test.tsx +1 -0
  5. package/src/App.toast.test.tsx +27 -1
  6. package/src/App.tsx +52 -12
  7. package/src/App.workbench.test.tsx +290 -2
  8. package/src/app-screens.tsx +34 -8
  9. package/src/components/AppShell.zoom.test.tsx +16 -3
  10. package/src/components/ChangesColumn.tsx +10 -4
  11. package/src/components/ChangesFileTree.test.tsx +122 -4
  12. package/src/components/DiffContentView.scroll.test.tsx +285 -1
  13. package/src/components/DiffContentView.tsx +214 -56
  14. package/src/components/EditorGrid.split.test.tsx +118 -0
  15. package/src/components/EditorGrid.stories.tsx +27 -0
  16. package/src/components/EditorGrid.tsx +170 -50
  17. package/src/components/EditorGrid.zoom.test.tsx +65 -1
  18. package/src/components/GridTabContent.actions.test.tsx +151 -0
  19. package/src/components/GridTabContent.stories.tsx +2 -2
  20. package/src/components/GridTabContent.tsx +5 -1
  21. package/src/components/ProjectTreeView.test.tsx +166 -8
  22. package/src/components/ProjectTreeView.tsx +53 -27
  23. package/src/components/ScrollArea.tsx +21 -2
  24. package/src/components/Toast.tsx +3 -1
  25. package/src/components/WelcomePlayground.test.tsx +28 -1
  26. package/src/components/__snapshots__/stories.test.tsx.snap +41 -21
  27. package/src/components/editor-grid.test.ts +87 -0
  28. package/src/components/editor-grid.ts +142 -6
  29. package/src/components/primitives/Tree.tsx +51 -15
  30. package/src/run.ts +6 -0
  31. package/src/thread-controller.curate.test.ts +27 -0
  32. package/src/thread-controller.live-diff.test.ts +30 -0
  33. package/src/thread-controller.ts +29 -7
  34. package/src/use-annotation-surface.tsx +86 -19
  35. package/src/use-changes-workbench.ts +30 -18
  36. package/src/use-frame-measure.ts +40 -7
  37. package/src/use-repo-changes.ts +5 -1
  38. package/src/use-settings-dialog.tsx +1 -13
  39. package/src/use-shared-keyboard.ts +55 -0
  40. package/src/use-terminal-virtualizer.ts +8 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.92",
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.92",
31
- "@cueloop/extension-api": "0.1.0-alpha.92",
32
- "@cueloop/integration-obsidian": "0.1.0-alpha.92",
33
- "@cueloop/schema": "0.1.0-alpha.92",
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"
@@ -0,0 +1,186 @@
1
+ /** Keyboard scrolling in a changed-file tab uses caret navigation, even when the file was deleted. */
2
+
3
+ import { expect, test } from "bun:test";
4
+ import { mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import React from "react";
8
+ import { ScrollBoxRenderable, type Renderable } from "@opentui/core";
9
+ import { DaemonServer } from "@cueloop/daemon";
10
+ import { App } from "./App";
11
+ import { annotationPaletteFor } from "./annotation-palette";
12
+ import { DARK } from "./theme";
13
+ import {
14
+ isolateUserConfig,
15
+ locateText,
16
+ press,
17
+ renderReadyApp,
18
+ settle,
19
+ waitForText,
20
+ } from "./test-support";
21
+
22
+ function findScrollbox(node: Renderable): ScrollBoxRenderable | null {
23
+ if (node.id === "diff-scroll" && node instanceof ScrollBoxRenderable) return node;
24
+
25
+ for (const child of node.getChildren()) {
26
+ const found = findScrollbox(child);
27
+
28
+ if (found) return found;
29
+ }
30
+
31
+ return null;
32
+ }
33
+
34
+ function hex(color: { toInts(): [number, number, number, number] }): string {
35
+ const [red, green, blue] = color.toInts();
36
+
37
+ return "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
38
+ }
39
+
40
+ test("deleted file caret scrolls one row and stays at the file boundaries", async () => {
41
+ const home = mkdtempSync(join(tmpdir(), "cueloop-scroll-home-"));
42
+ const repo = mkdtempSync(join(tmpdir(), "cueloop-scroll-repo-"));
43
+ const restoreConfig = isolateUserConfig(home);
44
+ const server = new DaemonServer({ home, idleExitMs: 0 });
45
+ let setup: Awaited<ReturnType<typeof renderReadyApp>> | undefined;
46
+
47
+ try {
48
+ const file = join(repo, "long.md");
49
+ const lines = Array.from({ length: 180 }, (_, index) =>
50
+ index === 113
51
+ ? "[nodejs/node#36005](https://github.com/nodejs/node/issues/36005)."
52
+ : `row ${String(index).padStart(3, "0")} ${"a paragraph with wrapped text and a link [source](https://example.test/page) ".repeat((index % 3) + 1)}`,
53
+ );
54
+ const git = (args: string[]) => Bun.spawnSync(["git", "-C", repo, ...args]);
55
+
56
+ writeFileSync(file, lines.join("\n") + "\n");
57
+ git(["init", "-q"]);
58
+ git(["config", "user.email", "fixture@example.com"]);
59
+ git(["config", "user.name", "Fixture"]);
60
+ git(["add", "-A"]);
61
+ git(["commit", "-q", "-m", "seed"]);
62
+ unlinkSync(file);
63
+ server.start();
64
+ setup = await renderReadyApp(<App home={home} sessionId={undefined} cwd={repo} />, {
65
+ width: 180,
66
+ height: 48,
67
+ });
68
+ await waitForText(setup, "long.md");
69
+ const treeFile = locateText(setup, "long.md");
70
+
71
+ await setup.mockMouse.click(treeFile.column, treeFile.row);
72
+ await waitForText(setup, "row 00");
73
+ const box = findScrollbox(setup.renderer.root);
74
+
75
+ if (!box) throw new Error("file diff scrollbox missing");
76
+ for (let offset = 0; offset < 1000; offset += 20) {
77
+ box.scrollTo({ x: 0, y: offset });
78
+ await settle(setup);
79
+ if (setup.captureCharFrame().includes("row 130")) break;
80
+ }
81
+ const target = locateText(setup, "row 130");
82
+
83
+ await setup.mockMouse.click(target.column, target.row);
84
+ await press(setup, "escape");
85
+
86
+ const steps: number[] = [];
87
+ const caretCell = annotationPaletteFor(DARK).caretCell;
88
+ const frameTrace: Array<{ markers: number[]; caret: number }> = [];
89
+ const captureFrame = () => {
90
+ const lines = setup!.captureCharFrame().split("\n");
91
+ const markers = lines.flatMap((line, row) => (line.includes("▎") ? [row] : []));
92
+ const caret = setup!
93
+ .captureSpans()
94
+ .lines.findIndex((line) => line.spans.some((span) => hex(span.bg) === caretCell));
95
+
96
+ frameTrace.push({ markers, caret });
97
+ };
98
+
99
+ setup.renderer.on("frame", captureFrame);
100
+
101
+ for (let index = 0; index < 12; index++) {
102
+ const before = box.scrollTop;
103
+ await press(setup, "down");
104
+ steps.push(box.scrollTop - before);
105
+ const caretRow = setup
106
+ .captureSpans()
107
+ .lines.findIndex((line) => line.spans.some((span) => hex(span.bg) === caretCell));
108
+ const markerRows = setup
109
+ .captureCharFrame()
110
+ .split("\n")
111
+ .flatMap((line, row) => (line.includes("▎") ? [row] : []));
112
+
113
+ expect(caretRow).toBeGreaterThanOrEqual(0);
114
+ expect(markerRows).toEqual([caretRow]);
115
+ }
116
+
117
+ expect(Math.max(...steps)).toBeLessThanOrEqual(1);
118
+ expect(Math.min(...steps)).toBeGreaterThanOrEqual(0);
119
+ let anchored = false;
120
+
121
+ for (let index = 0; index < 80; index++) {
122
+ await press(setup, "up");
123
+ const marker = setup
124
+ .captureCharFrame()
125
+ .split("\n")
126
+ .findIndex((line) => line.includes("▎"));
127
+
128
+ if (marker === box.viewport.screenY) anchored = true;
129
+ if (anchored && box.scrollTop > 0) expect(marker).toBe(box.viewport.screenY);
130
+ }
131
+ expect(anchored).toBe(true);
132
+ box.scrollTo({ x: 0, y: 100000 });
133
+ await settle(setup);
134
+ const last = locateText(setup, "row 179");
135
+
136
+ await setup.mockMouse.click(last.column, last.row);
137
+ await press(setup, "escape");
138
+ let lastMarker = -1;
139
+
140
+ for (let index = 0; index < 12; index++) {
141
+ const traceStart = frameTrace.length;
142
+ const before = box.scrollTop;
143
+
144
+ await press(setup, "down");
145
+ const states = frameTrace.slice(traceStart);
146
+
147
+ expect(box.scrollTop).toBe(before);
148
+ expect(states.length).toBeGreaterThan(0);
149
+ for (const state of states) {
150
+ expect(state.markers).toEqual([state.caret]);
151
+ expect(state.caret).toBeGreaterThanOrEqual(lastMarker);
152
+ lastMarker = state.caret;
153
+ }
154
+ }
155
+ box.scrollTo({ x: 0, y: 0 });
156
+ await settle(setup);
157
+ const first = locateText(setup, "row 000");
158
+
159
+ await setup.mockMouse.click(first.column, first.row);
160
+ await press(setup, "escape");
161
+ let firstMarker = Number.POSITIVE_INFINITY;
162
+
163
+ for (let index = 0; index < 12; index++) {
164
+ const traceStart = frameTrace.length;
165
+ const before = box.scrollTop;
166
+
167
+ await press(setup, "up");
168
+ const states = frameTrace.slice(traceStart);
169
+
170
+ expect(box.scrollTop).toBe(before);
171
+ expect(states.length).toBeGreaterThan(0);
172
+ for (const state of states) {
173
+ expect(state.markers).toEqual([state.caret]);
174
+ expect(state.caret).toBeLessThanOrEqual(firstMarker);
175
+ firstMarker = state.caret;
176
+ }
177
+ }
178
+ setup.renderer.off("frame", captureFrame);
179
+ } finally {
180
+ setup?.renderer.destroy();
181
+ server.stop();
182
+ restoreConfig();
183
+ rmSync(home, { recursive: true, force: true });
184
+ rmSync(repo, { recursive: true, force: true });
185
+ }
186
+ }, 30000);
@@ -10,7 +10,6 @@ import type { Thread } from "@cueloop/schema";
10
10
  import { App } from "./App";
11
11
  import { pullRequestReviewLayout } from "./launch-layout";
12
12
  import { DARK } from "./theme";
13
- import { NERD } from "./components/primitives/icons";
14
13
  import {
15
14
  clickText,
16
15
  dragText,
@@ -220,7 +219,7 @@ describe("diff review", () => {
220
219
  await waitForText(setup, "● Existing note.");
221
220
  });
222
221
 
223
- test("option+c folds the caret's file to its band; the chevron restores its body", async () => {
222
+ test("option+c folds the caret's file to its band; its title restores the body", async () => {
224
223
  // Arrange - the caret opens on the file's first code line
225
224
  const setup = await renderApp();
226
225
 
@@ -234,8 +233,8 @@ describe("diff review", () => {
234
233
  expect(collapsed).toContain("src/store.ts");
235
234
  expect(collapsed).toContain("+1");
236
235
 
237
- // Act - the band's chevron unfolds it again
238
- await clickText(setup, NERD.chevronRight);
236
+ // Act - the title unfolds it again
237
+ await clickText(setup, "src/store.ts");
239
238
 
240
239
  // Assert
241
240
  await waitForText(setup, "new Map()");
package/src/App.test.tsx CHANGED
@@ -442,6 +442,7 @@ describe("the thread view and the menu", () => {
442
442
  const dialog = setup.captureCharFrame();
443
443
 
444
444
  expect(dialog).toContain("nav mode");
445
+ expect(dialog).toContain("open selected file or toggle folder");
445
446
  expect(dialog).toContain("place the caret");
446
447
  expect(dialog).not.toContain("grow/shrink");
447
448
 
@@ -1,6 +1,6 @@
1
1
  /** The share toast is non-modal: while it is up, escape still cancels an open overlay. */
2
2
 
3
- import { afterEach, beforeEach, describe, mock, test } from "bun:test";
3
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
4
4
  import { mkdtempSync, rmSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
@@ -13,6 +13,7 @@ import type { ShareTransport } from "./thread-controller";
13
13
  import {
14
14
  clickText,
15
15
  isolateUserConfig,
16
+ locateText,
16
17
  press,
17
18
  pressKey,
18
19
  typeText,
@@ -63,6 +64,31 @@ afterEach(() => {
63
64
  });
64
65
 
65
66
  describe("share toast", () => {
67
+ test("clicking outside dismisses the toast while clicking inside keeps it open", async () => {
68
+ const setup = await testRender(
69
+ <App home={home} sessionId={session.id} shareTransport={shareTransport} />,
70
+ { width: 120, height: 32 },
71
+ );
72
+
73
+ await waitForText(setup, "cueloop");
74
+ await pressKey(setup, "s", { ctrl: true });
75
+ await waitForText(setup, "+ new link");
76
+ await press(setup, "enter");
77
+ await press(setup, "enter");
78
+ await waitForText(setup, "link name");
79
+ await press(setup, "enter");
80
+ await waitForText(setup, "link copied");
81
+ await press(setup, "escape");
82
+ await waitForTextGone(setup, "share externally");
83
+ const body = locateText(setup, "ssh p_share01@cueloop.dev");
84
+
85
+ await setup.mockMouse.click(body.column, body.row);
86
+ expect(setup.captureCharFrame()).toContain("link copied");
87
+ await setup.mockMouse.click(1, 1);
88
+ await waitForTextGone(setup, "link copied");
89
+ setup.renderer.destroy();
90
+ });
91
+
66
92
  test("escape cancels an open composer even while the toast is up", async () => {
67
93
  // Arrange
68
94
  const setup = await testRender(
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,12 +342,13 @@ 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;
332
349
  frozenFiles?: readonly DiffFileContents[];
333
- onOpenChangedFile: (path: string) => void;
334
- onOpenProjectFile: (path: string) => void;
350
+ onOpenChangedFile: (path: string, persistent?: boolean) => void;
351
+ onOpenProjectFile: (path: string, persistent?: boolean) => void;
335
352
  commentCounts?: ReadonlyMap<string, number>;
336
353
  focused?: boolean;
337
354
  theme: Theme;
@@ -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") {
@@ -391,12 +409,20 @@ function usableScreenReached(
391
409
  /** The keybinds dialog content: the thread grammar while the thread view owns the keys. */
392
410
  function cheatsheetFor(keyBindings: KeyBindings, threadViewActive: boolean): CheatsheetSection[] {
393
411
  const base = keyBindings.cheatsheet();
412
+ const fileTree: CheatsheetSection = {
413
+ title: "Changes / Project files",
414
+ entries: [
415
+ { keys: "enter", label: "open selected file or toggle folder" },
416
+ { keys: "tab", label: "cycle panes" },
417
+ ],
418
+ };
394
419
 
395
420
  if (!threadViewActive) {
396
- return base;
421
+ return [fileTree, ...base];
397
422
  }
398
423
 
399
424
  return [
425
+ fileTree,
400
426
  ...THREAD_VIEW_CHEATSHEET,
401
427
  { title: "Nav mode · session", entries: sessionCommandEntries() },
402
428
  { title: "Nav mode · diff", entries: diffCommandEntries() },
@@ -523,6 +549,8 @@ export function reconciledFocus(navigable: FocusPane[], focusedPane: FocusPane):
523
549
  export function App({
524
550
  home,
525
551
  sessionId,
552
+ initialReview,
553
+ initialClient,
526
554
  cwd,
527
555
  readOnly = false,
528
556
  onExit,
@@ -543,6 +571,8 @@ export function App({
543
571
  createReviewController({
544
572
  home,
545
573
  sessionId,
574
+ ...initialReviewControllerOptions(initialReview),
575
+ initialClient,
546
576
  cwd,
547
577
  readOnly: observer,
548
578
  onExit,
@@ -573,6 +603,11 @@ export function App({
573
603
  );
574
604
  const { session, inbox, status, toast, error, completion, editOrphanCount, walk } =
575
605
  useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
606
+ const initialReviewForFirstMount = useRef(initialReview);
607
+
608
+ useEffect(() => {
609
+ initialReviewForFirstMount.current = undefined;
610
+ }, []);
576
611
 
577
612
  // A shared plan you own polls for collaborator notes while it is open; the
578
613
  // merge refreshes through the normal event path. Stops on leave.
@@ -630,7 +665,7 @@ export function App({
630
665
  const [menuDialog, setMenuDialog] = useState<"keybinds" | "settings" | null>(null);
631
666
  const [shareDialogOpen, setShareDialogOpen] = useState(false);
632
667
  const [autoClose, setAutoClose] = useState<AutoClose>("off");
633
- // unified or side-by-side diff; split only lays out when the Changes pane is zoomed
668
+ // unified or side-by-side diff; split lays out when the Changes pane is zoomed
634
669
  const [diffView, setDiffView] = useState<DiffViewMode>("split");
635
670
  const [defaultMessage, setDefaultMessage] = useState<MessageOutcome>("approved");
636
671
  const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
@@ -656,10 +691,6 @@ export function App({
656
691
  const [skills, setSkills] = useState<SlashItem[]>([]);
657
692
  const [reviewSkill, setReviewSkill] = useState("code-review");
658
693
  const [reviewWorkspace, setReviewWorkspace] = useState<ReviewWorkspaceMode>("worktree");
659
- const reviewSkillOptions = useMemo(
660
- () => [...new Set([reviewSkill, "code-review", ...skills.map((skill) => skill.name)])],
661
- [reviewSkill, skills],
662
- );
663
694
  const paletteNames = useMemo(
664
695
  () => new Set(mergeSlashItems(slashItemsFrom(quickActions), skills).map((item) => item.name)),
665
696
  [quickActions, skills],
@@ -767,8 +798,6 @@ export function App({
767
798
  setMode({ type: "renameSelf", text: identity.name ?? "" });
768
799
  },
769
800
  reviewSkill,
770
- reviewSkillOptions,
771
- setReviewSkill,
772
801
  reviewWorkspace,
773
802
  setReviewWorkspace,
774
803
  });
@@ -1116,6 +1145,7 @@ export function App({
1116
1145
  onPin={togglePin}
1117
1146
  onRename={(id, title) => setMode({ type: "renameThread", sessionId: id, text: title })}
1118
1147
  quickActions={quickActions}
1148
+ diffView={diffView}
1119
1149
  onWelcomeComposingChange={setWelcomeComposing}
1120
1150
  layout={layout}
1121
1151
  />
@@ -1367,6 +1397,7 @@ export function App({
1367
1397
  commentCounts={diffCommentCounts}
1368
1398
  onFocusGroup={workbench.focusGroup}
1369
1399
  onActivateTab={workbench.activate}
1400
+ onKeepTab={workbench.keepTab}
1370
1401
  onCloseTab={workbench.close}
1371
1402
  onSplit={workbench.split}
1372
1403
  onZoom={workbench.toggleZoom}
@@ -1442,13 +1473,21 @@ export function App({
1442
1473
  <ProjectPanelBody
1443
1474
  mode={workbench.projectMode}
1444
1475
  loadChanges={() => controller.repoChanges()}
1476
+ initialFiles={initialReviewFiles(
1477
+ initialReviewForFirstMount.current,
1478
+ activeSession.id,
1479
+ )}
1445
1480
  loadProjectFiles={() => controller.repoFiles()}
1446
1481
  diffSourceKey={activeSession.id}
1447
1482
  {...projectDiffFiles(activeSession)}
1448
1483
  // the Changes navigator always opens a changed file as a diff - a diff review shows its
1449
1484
  // captured snapshot, every other thread the live working-tree diff
1450
- onOpenChangedFile={(path) => workbench.openFile(path, "diff")}
1451
- onOpenProjectFile={(path) => workbench.openFile(path, "contents")}
1485
+ onOpenChangedFile={(path, persistent) =>
1486
+ workbench.openFile(path, "diff", persistent)
1487
+ }
1488
+ onOpenProjectFile={(path, persistent) =>
1489
+ workbench.openFile(path, "contents", persistent)
1490
+ }
1452
1491
  commentCounts={diffCommentCounts}
1453
1492
  focused={focusedPane === "project"}
1454
1493
  theme={theme}
@@ -1466,6 +1505,7 @@ export function App({
1466
1505
  theme={theme}
1467
1506
  mode={mode}
1468
1507
  toast={toast}
1508
+ onDismissToast={() => controller.dismissToast()}
1469
1509
  setMode={setMode}
1470
1510
  dispatch={dispatch}
1471
1511
  />