@cueloop/client 0.1.0-alpha.89 → 0.1.0-alpha.90

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.89",
3
+ "version": "0.1.0-alpha.90",
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,10 +27,10 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@cueloop/daemon": "0.1.0-alpha.89",
31
- "@cueloop/extension-api": "0.1.0-alpha.89",
32
- "@cueloop/integration-obsidian": "0.1.0-alpha.89",
33
- "@cueloop/schema": "0.1.0-alpha.89",
30
+ "@cueloop/daemon": "0.1.0-alpha.90",
31
+ "@cueloop/extension-api": "0.1.0-alpha.90",
32
+ "@cueloop/integration-obsidian": "0.1.0-alpha.90",
33
+ "@cueloop/schema": "0.1.0-alpha.90",
34
34
  "@opentui/core": "^0.5.11",
35
35
  "@opentui/keymap": "0.5.11",
36
36
  "@opentui/react": "^0.5.11",
@@ -68,6 +68,25 @@ async function renderApp(sessionId = session.id) {
68
68
  }
69
69
 
70
70
  describe("diff review", () => {
71
+ test("a submitted JJ review uses the existing refresh action and shows its source", async () => {
72
+ const jjReview = server.core.sessionCreate({
73
+ workspace: { repoRoot: "/repo", branch: "jj" },
74
+ artifact: {
75
+ type: "diff",
76
+ content: PATCH,
77
+ meta: { title: "JJ change", vcs: "jj", vcsChangeId: "abcdefghijklmnopqrstuvwxyzaaaaaa" },
78
+ },
79
+ });
80
+ const setup = await renderReadyApp(<App home={home} sessionId={jjReview.id} />, {
81
+ width: 120,
82
+ height: 30,
83
+ });
84
+
85
+ await waitForText(setup, "new Map()");
86
+ expect(setup.captureCharFrame()).toContain("vcs: jj");
87
+ expect(setup.captureCharFrame()).toContain("refresh");
88
+ });
89
+
71
90
  test("pull request review shows its brief beside Changes and signals a moved head", async () => {
72
91
  const pullRequest = server.core.sessionCreate({
73
92
  workspace: { repoRoot: "/repo", branch: "detached" },
@@ -8,6 +8,7 @@ import React from "react";
8
8
  import { DaemonServer } from "@cueloop/daemon";
9
9
  import type { Thread } from "@cueloop/schema";
10
10
  import { App } from "./App";
11
+ import { DARK } from "./theme";
11
12
  import { isolateUserConfig, renderReadyApp, settle, waitForText } from "./test-support";
12
13
 
13
14
  const DOC = `# PromoCard
@@ -53,6 +54,41 @@ afterEach(() => {
53
54
  });
54
55
 
55
56
  describe("markdown prototype review", () => {
57
+ test("syntax-highlights TypeScript fences without changing their text", async () => {
58
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
59
+ width: 120,
60
+ height: 40,
61
+ });
62
+
63
+ await waitForText(setup, "PromoCardProps");
64
+ let keywordHighlighted = false;
65
+
66
+ for (let attempt = 0; attempt < 60 && !keywordHighlighted; attempt++) {
67
+ await setup.renderOnce();
68
+ await new Promise((resolve) => setTimeout(resolve, 20));
69
+ const accentText = setup
70
+ .captureSpans()
71
+ .lines.flatMap((line) =>
72
+ line.spans
73
+ .filter((span) => {
74
+ const [red, green, blue] = span.fg.toInts();
75
+
76
+ return (
77
+ `#${[red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("")}` ===
78
+ DARK.accent
79
+ );
80
+ })
81
+ .map((span) => span.text),
82
+ )
83
+ .join("");
84
+
85
+ keywordHighlighted = accentText.includes("interface");
86
+ }
87
+
88
+ expect(keywordHighlighted).toBe(true);
89
+ expect(setup.captureCharFrame()).toContain("interface PromoCardProps {");
90
+ }, 25000);
91
+
56
92
  test("renders as a markdown design doc, not the pixel view", async () => {
57
93
  const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
58
94
  width: 120,
package/src/App.tsx CHANGED
@@ -52,9 +52,12 @@ import { ChangesFileTree } from "./components/ChangesColumn";
52
52
  import { MenuControlProvider, useMenuControlState } from "./components/menu-control";
53
53
  import { ProjectTreeView } from "./components/ProjectTreeView";
54
54
  import { AppShell, type FocusPane, type ProjectPanelMode } from "./components/AppShell";
55
+ import type { ClientExtensionRegistry } from "./client-extension-registry";
56
+ import type { ExtensionUIContext } from "@cueloop/extension-api/client";
55
57
  import { EditorGrid } from "./components/EditorGrid";
56
58
  import { GridTabContent } from "./components/GridTabContent";
57
59
  import { useChangesWorkbench } from "./use-changes-workbench";
60
+ import { useRepoChanges } from "./use-repo-changes";
58
61
  import { useThreadBodyEditing } from "./use-thread-body-editing";
59
62
  import { useRememberLayout } from "./use-remember-layout";
60
63
  import type { LaunchLayout } from "./launch-layout";
@@ -70,7 +73,7 @@ import {
70
73
  treeCommandEntries,
71
74
  } from "./thread-chords";
72
75
  import { type DiffFoldControls } from "./components/DiffContentView";
73
- import { commentCountsByFile } from "./view-diff";
76
+ import { commentCountsByFile, readsFrozenDiff } from "./view-diff";
74
77
  import { annotationTarget, isAddressed, isAgentNote, threadShareLinks } from "@cueloop/schema";
75
78
  import type {
76
79
  Annotation,
@@ -145,6 +148,11 @@ export interface AppProps {
145
148
  layout?: LaunchLayout;
146
149
  /** Serve mode: the frozen working-tree diff an observer reads for the served workbench thread. */
147
150
  servedArtifact?: Artifact;
151
+ extensionRegistry?: ClientExtensionRegistry;
152
+ }
153
+
154
+ function extensionContext(cwd: string | undefined, threadId: string | null): ExtensionUIContext {
155
+ return { workspace: cwd ?? process.cwd(), threadId };
148
156
  }
149
157
 
150
158
  /** True while the drop-up or one of its dialogs is open and owns the keyboard. */
@@ -167,6 +175,14 @@ function shareThreadName(session: Thread | null): string {
167
175
  return session ? threadTitle(session) : "";
168
176
  }
169
177
 
178
+ /** Show the diff source before long titles can truncate it in the header. */
179
+ function reviewThreadTitle(session: Thread): string {
180
+ if (session.artifact.type === "diff" && session.artifact.meta.vcs)
181
+ return `vcs: ${session.artifact.meta.vcs} · ${threadTitle(session)}`;
182
+
183
+ return threadTitle(session);
184
+ }
185
+
170
186
  /** The drop-up chrome or a floating popover menu (thread actions, editor split) holds the keyboard. */
171
187
  function keyboardHeldByMenu(
172
188
  menuDialog: "keybinds" | "settings" | null,
@@ -253,7 +269,7 @@ function ownerThreadActions(actions: {
253
269
  );
254
270
  }
255
271
 
256
- function refreshPullRequestAction(onRefresh: () => void, theme: Theme): React.ReactNode {
272
+ function refreshDiffAction(onRefresh: () => void, theme: Theme): React.ReactNode {
257
273
  return (
258
274
  <Toolbar>
259
275
  <Button onPress={onRefresh} foreground={theme.warning} theme={theme}>
@@ -311,39 +327,25 @@ function ProjectPanelBody(props: {
311
327
  mode: ProjectPanelMode;
312
328
  loadChanges: () => Promise<readonly DiffFileContents[]>;
313
329
  loadProjectFiles: () => Promise<string[]>;
314
- reloadKey: string;
330
+ diffSourceKey: string;
331
+ liveWorkingTree: boolean;
332
+ frozenFiles?: readonly DiffFileContents[];
315
333
  onOpenChangedFile: (path: string) => void;
316
334
  onOpenProjectFile: (path: string) => void;
317
335
  commentCounts?: ReadonlyMap<string, number>;
318
336
  focused?: boolean;
319
337
  theme: Theme;
320
338
  }): React.ReactNode {
321
- const [changes, setChanges] = useState<readonly DiffFileContents[]>([]);
322
- const loadRef = useRef(props.loadChanges);
323
- useEffect(() => {
324
- loadRef.current = props.loadChanges;
339
+ const changes = useRepoChanges({
340
+ loadChanges: props.loadChanges,
341
+ visible: props.mode === "changes" && props.liveWorkingTree,
342
+ diffSourceKey: props.diffSourceKey,
325
343
  });
326
- useEffect(() => {
327
- let alive = true;
328
-
329
- void loadRef.current().then(
330
- (files) => {
331
- if (alive) setChanges(files);
332
- },
333
- () => {
334
- if (alive) setChanges([]);
335
- },
336
- );
337
-
338
- return () => {
339
- alive = false;
340
- };
341
- }, [props.reloadKey, props.mode]);
342
344
 
343
345
  if (props.mode === "changes") {
344
346
  return (
345
347
  <ChangesFileTree
346
- files={changes}
348
+ files={props.liveWorkingTree ? changes : (props.frozenFiles ?? [])}
347
349
  onSelectFile={props.onOpenChangedFile}
348
350
  commentCounts={props.commentCounts}
349
351
  focused={props.focused}
@@ -353,7 +355,7 @@ function ProjectPanelBody(props: {
353
355
  }
354
356
  return (
355
357
  <ProjectTreeView
356
- key={props.reloadKey}
358
+ key={props.diffSourceKey}
357
359
  loadFiles={props.loadProjectFiles}
358
360
  onSelectFile={props.onOpenProjectFile}
359
361
  focused={props.focused}
@@ -362,6 +364,21 @@ function ProjectPanelBody(props: {
362
364
  );
363
365
  }
364
366
 
367
+ /** Frozen reviews read their captured files directly; live workspaces poll the repo. */
368
+ interface ProjectDiffFiles {
369
+ liveWorkingTree: boolean;
370
+ frozenFiles?: readonly DiffFileContents[];
371
+ }
372
+
373
+ function projectDiffFiles(session: Thread): ProjectDiffFiles {
374
+ const frozen = readsFrozenDiff(session);
375
+
376
+ return {
377
+ liveWorkingTree: !frozen,
378
+ frozenFiles: frozen ? session.artifact.files : undefined,
379
+ };
380
+ }
381
+
365
382
  /** The render tree has left the connecting screen: an error, a session, or the no-thread shell. */
366
383
  function usableScreenReached(
367
384
  error: string | null,
@@ -514,6 +531,7 @@ export function App({
514
531
  appearance = "dark",
515
532
  layout,
516
533
  servedArtifact,
534
+ extensionRegistry,
517
535
  }: AppProps): React.ReactNode {
518
536
  const { observer, isOwner } = computeRoleCapabilities(readOnly, role);
519
537
  const controller = useMemo(
@@ -532,6 +550,10 @@ export function App({
532
550
  // eslint-disable-next-line react-hooks/exhaustive-deps
533
551
  [home, sessionId],
534
552
  );
553
+ const showExtensionError = useCallback(
554
+ (message: string) => controller.showToast(message),
555
+ [controller],
556
+ );
535
557
 
536
558
  useEffect(() => {
537
559
  controller.connect();
@@ -1069,6 +1091,9 @@ export function App({
1069
1091
  <SlashSkillsContext.Provider value={skills}>
1070
1092
  <PaletteNamesContext.Provider value={paletteNames}>
1071
1093
  <NoThreadShell
1094
+ toast={toast}
1095
+ extensionRegistry={extensionRegistry}
1096
+ extensionContext={extensionContext(cwd, null)}
1072
1097
  rows={grouped.rows}
1073
1098
  inboxCursor={inboxCursor}
1074
1099
  onOpenThread={openThread}
@@ -1137,21 +1162,16 @@ export function App({
1137
1162
  setMode,
1138
1163
  dispatch,
1139
1164
  });
1140
- const {
1141
- showOwnerActions,
1142
- showPullRequestRefresh,
1143
- prototypeCanComment,
1144
- chromeHidden,
1145
- prototypePath,
1146
- } = buildRenderFlags({
1147
- session: activeSession,
1148
- isOwner,
1149
- isDiff,
1150
- isPixelPrototype,
1151
- resolved,
1152
- menuDialog,
1153
- resolvedIds,
1154
- });
1165
+ const { showOwnerActions, showDiffRefresh, prototypeCanComment, chromeHidden, prototypePath } =
1166
+ buildRenderFlags({
1167
+ session: activeSession,
1168
+ isOwner,
1169
+ isDiff,
1170
+ isPixelPrototype,
1171
+ resolved,
1172
+ menuDialog,
1173
+ resolvedIds,
1174
+ });
1155
1175
 
1156
1176
  const onEditRequest = (): void => {
1157
1177
  // A share viewer/observer has no Edit affordance (the button is hidden), so
@@ -1190,10 +1210,17 @@ export function App({
1190
1210
  <ThemeProvider theme={theme}>
1191
1211
  <MenuControlProvider value={menuControl}>
1192
1212
  <AppShell
1213
+ extensionRegistry={extensionRegistry}
1214
+ extensionContext={{
1215
+ workspace: activeSession.workspace.repoRoot,
1216
+ threadId: activeSession.id,
1217
+ }}
1218
+ onExtensionError={showExtensionError}
1193
1219
  sidebarOpen={sidebarOpen}
1194
1220
  onToggleSidebar={() => setSidebarOpen((open) => !open)}
1195
1221
  onOpenMenu={openSettings}
1196
1222
  onFocusPane={setFocusedPane}
1223
+ focusedPane={focusedPane}
1197
1224
  threadsPanel={
1198
1225
  <scrollbox style={{ flexGrow: 1 }} focused={false}>
1199
1226
  <ThreadTree
@@ -1215,7 +1242,7 @@ export function App({
1215
1242
  />
1216
1243
  </scrollbox>
1217
1244
  }
1218
- threadTitle={threadTitle(activeSession)}
1245
+ threadTitle={reviewThreadTitle(activeSession)}
1219
1246
  threadActions={
1220
1247
  showOwnerActions
1221
1248
  ? ownerThreadActions({
@@ -1225,8 +1252,8 @@ export function App({
1225
1252
  onShare: () => dispatch({ type: "share" }),
1226
1253
  theme,
1227
1254
  })
1228
- : showPullRequestRefresh
1229
- ? refreshPullRequestAction(() => void controller.refreshPullRequest(), theme)
1255
+ : showDiffRefresh
1256
+ ? refreshDiffAction(() => void controller.refreshDiff(), theme)
1230
1257
  : undefined
1231
1258
  }
1232
1259
  threadPanel={
@@ -1412,7 +1439,8 @@ export function App({
1412
1439
  mode={workbench.projectMode}
1413
1440
  loadChanges={() => controller.repoChanges()}
1414
1441
  loadProjectFiles={() => controller.repoFiles()}
1415
- reloadKey={activeSession.id}
1442
+ diffSourceKey={activeSession.id}
1443
+ {...projectDiffFiles(activeSession)}
1416
1444
  // the Changes navigator always opens a changed file as a diff - a diff review shows its
1417
1445
  // captured snapshot, every other thread the live working-tree diff
1418
1446
  onOpenChangedFile={(path) => workbench.openFile(path, "diff")}
@@ -113,6 +113,71 @@ const rightToggleColumn = (setup: Setup): number => toggleColumn(setup, NERD.sid
113
113
  const railToggleColumn = (setup: Setup): number => toggleColumn(setup, NERD.sidebarRightOff);
114
114
 
115
115
  describe("the four-pane workbench", () => {
116
+ test("a live thread's Changes tree follows edits and a clean working tree", async () => {
117
+ const liveSession = server.core.sessionCreate({
118
+ workspace: { repoRoot: repo, branch: "main" },
119
+ artifact: { type: "diff", content: "", meta: { title: "live changes", workbench: true } },
120
+ });
121
+ writeFileSync(join(repo, "README.md"), "# Edited\n");
122
+ const setup = await renderReadyApp(
123
+ <App home={home} sessionId={liveSession.id} layout={reviewLayout()} />,
124
+ {
125
+ width: 160,
126
+ height: 20,
127
+ },
128
+ );
129
+
130
+ await waitForText(setup, "README.md");
131
+ writeFileSync(join(repo, "README.md"), "# Workbench Fixture\n\nA tiny tracked repo.\n");
132
+ await waitForState(setup, () => !setup.captureCharFrame().includes("README.md"));
133
+ writeFileSync(join(repo, "src", "util.ts"), "export const noop = () => 1;\n");
134
+ await waitForText(setup, "util.ts");
135
+ }, 12_000);
136
+
137
+ test("a frozen diff's Changes tree follows a new captured revision", async () => {
138
+ const frozen = server.core.sessionCreate({
139
+ workspace: { repoRoot: repo, branch: "main" },
140
+ artifact: {
141
+ type: "diff",
142
+ content: "diff --git a/README.md b/README.md\n",
143
+ files: [
144
+ {
145
+ path: "README.md",
146
+ status: "modified",
147
+ oldContents: "before\n",
148
+ newContents: "after\n",
149
+ },
150
+ ],
151
+ meta: { title: "captured diff" },
152
+ },
153
+ });
154
+ const setup = await renderReadyApp(
155
+ <App home={home} sessionId={frozen.id} layout={reviewLayout()} />,
156
+ { width: 160, height: 20 },
157
+ );
158
+
159
+ await waitForText(setup, "README.md");
160
+ server.core.sessionSubmitRevision(
161
+ frozen.id,
162
+ "diff --git a/src/util.ts b/src/util.ts\n",
163
+ [],
164
+ [
165
+ {
166
+ path: "src/util.ts",
167
+ status: "modified",
168
+ oldContents: "before\n",
169
+ newContents: "after\n",
170
+ },
171
+ ],
172
+ );
173
+ await waitForState(setup, () => {
174
+ const frame = setup.captureCharFrame();
175
+
176
+ return frame.includes("util.ts") && !frame.includes("README.md");
177
+ });
178
+ setup.renderer.destroy();
179
+ });
180
+
116
181
  test("a diff session lays out the four panes with the diff toggle active", async () => {
117
182
  const setup = await renderApp();
118
183
 
@@ -423,6 +488,14 @@ describe("the bare-launch welcome shell", () => {
423
488
  expect(setup.captureCharFrame()).toContain("README.md");
424
489
  });
425
490
 
491
+ test("the changes tree clears after the launch repo becomes clean", async () => {
492
+ const setup = await renderWelcome();
493
+
494
+ await waitForText(setup, "README.md");
495
+ writeFileSync(join(welcomeRepo, "README.md"), "# Workbench Fixture\n\nA tiny tracked repo.\n");
496
+ await waitForState(setup, () => !setup.captureCharFrame().includes("README.md"));
497
+ });
498
+
426
499
  test("a review layout opens the Changes panel, listing the working-tree changes", async () => {
427
500
  const setup = await testRender(
428
501
  <App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} layout={reviewLayout()} />,
@@ -1,6 +1,6 @@
1
1
  import { ScrollArea } from "./components/ScrollArea";
2
- import React, { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react";
3
- import type { DiffFileContents, Thread, MessageOutcome } from "@cueloop/schema";
2
+ import React, { useCallback, useMemo, type Dispatch, type SetStateAction } from "react";
3
+ import type { Thread, MessageOutcome } from "@cueloop/schema";
4
4
  import { returnPaneFor } from "@cueloop/schema";
5
5
  import type { Theme } from "./theme";
6
6
  import type { QuickAction } from "./config";
@@ -22,6 +22,8 @@ import { CompletionOverlay } from "./components/CompletionOverlay";
22
22
  import { ThreadTree } from "./components/ThreadTree";
23
23
  import { WelcomePlayground } from "./components/WelcomePlayground";
24
24
  import { AppShell, type FocusPane, type ProjectPanelMode } from "./components/AppShell";
25
+ import type { ClientExtensionRegistry } from "./client-extension-registry";
26
+ import type { ExtensionUIContext } from "@cueloop/extension-api/client";
25
27
  import { EditorGrid } from "./components/EditorGrid";
26
28
  import { MenuControlProvider, type MenuControlApi } from "./components/menu-control";
27
29
  import { ProjectTreeView } from "./components/ProjectTreeView";
@@ -29,6 +31,7 @@ import { ChangesFileTree } from "./components/ChangesColumn";
29
31
  import { BareWorkbenchFileView, draftThread } from "./components/BareWorkbenchFileView";
30
32
  import { GridTabContent, type DiffSurfaceProps } from "./components/GridTabContent";
31
33
  import { useChangesWorkbench } from "./use-changes-workbench";
34
+ import { useRepoChanges } from "./use-repo-changes";
32
35
  import { ConfirmDialog } from "./components/ConfirmDialog";
33
36
  import { PromptDialog } from "./components/PromptDialog";
34
37
  import { WalkWizard } from "./components/WalkWizard";
@@ -115,24 +118,11 @@ function WelcomeProjectPanel({
115
118
  focused?: boolean;
116
119
  theme: Theme;
117
120
  }): React.ReactNode {
118
- const [changes, setChanges] = useState<readonly DiffFileContents[]>([]);
119
-
120
- useEffect(() => {
121
- let alive = true;
122
-
123
- void controller.repoChanges().then(
124
- (files) => {
125
- if (alive) setChanges(files);
126
- },
127
- () => {
128
- if (alive) setChanges([]);
129
- },
130
- );
131
-
132
- return () => {
133
- alive = false;
134
- };
135
- }, [controller]);
121
+ const changes = useRepoChanges({
122
+ loadChanges: () => controller.repoChanges(),
123
+ visible: mode === "changes",
124
+ diffSourceKey: "welcome",
125
+ });
136
126
 
137
127
  if (mode === "changes") {
138
128
  return (
@@ -162,6 +152,9 @@ function WelcomeProjectPanel({
162
152
  * the center for it. Closing the Welcome tab leaves a bare "select a thread" hint.
163
153
  */
164
154
  export function NoThreadShell(props: {
155
+ toast?: ToastState | null;
156
+ extensionRegistry?: ClientExtensionRegistry;
157
+ extensionContext?: ExtensionUIContext;
165
158
  rows: InboxRow[];
166
159
  inboxCursor: number;
167
160
  /** Open a sidebar thread and move the cursor onto it, so the row shows its selected backdrop at once. */
@@ -211,6 +204,10 @@ export function NoThreadShell(props: {
211
204
  onWelcomeComposingChange,
212
205
  layout,
213
206
  } = props;
207
+ const showExtensionError = useCallback(
208
+ (message: string) => controller.showToast(message),
209
+ [controller],
210
+ );
214
211
  const confirming = mode.type === "confirmDelete" ? mode : null;
215
212
  // The bare-launch shell is the same four panes as a thread: the Thread pane waits in its empty state
216
213
  // and a disposable Welcome tab rides in the Changes editor until a thread or diff is opened.
@@ -264,10 +261,14 @@ export function NoThreadShell(props: {
264
261
  <ThemeProvider theme={theme}>
265
262
  <MenuControlProvider value={menuControl}>
266
263
  <AppShell
264
+ extensionRegistry={props.extensionRegistry}
265
+ extensionContext={props.extensionContext}
266
+ onExtensionError={showExtensionError}
267
267
  sidebarOpen={sidebarOpen}
268
268
  onToggleSidebar={onToggleSidebar}
269
269
  onOpenMenu={onOpenMenu}
270
270
  onFocusPane={onFocusPane}
271
+ focusedPane={focusedPane}
271
272
  threadsPanel={
272
273
  <ScrollArea>
273
274
  <ThreadTree
@@ -394,6 +395,9 @@ export function NoThreadShell(props: {
394
395
  theme={theme}
395
396
  />
396
397
  ) : null}
398
+ {props.toast ? (
399
+ <Toast title={props.toast.title} body={props.toast.body} theme={theme} />
400
+ ) : null}
397
401
  </AppShell>
398
402
  </MenuControlProvider>
399
403
  </ThemeProvider>
@@ -115,7 +115,12 @@ export function buildRenderFlags(params: {
115
115
 
116
116
  return {
117
117
  showOwnerActions: isOwner && !isDiff && !resolved,
118
- showPullRequestRefresh: !resolved && session.artifact.meta.prRefreshHeadSha !== undefined,
118
+ showDiffRefresh:
119
+ !resolved &&
120
+ (session.artifact.meta.prRefreshHeadSha !== undefined ||
121
+ (session.artifact.type === "diff" &&
122
+ session.artifact.meta.vcsChangeId !== undefined &&
123
+ session.artifact.meta.workbench !== true)),
119
124
  prototypeCanComment: isOwner && !resolved,
120
125
  chromeHidden: menuDialog !== null,
121
126
  prototypePath: session.artifact.meta.prototypePath ?? "",
@@ -0,0 +1,61 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import React from "react";
3
+ import { ClientExtensionRegistry } from "./client-extension-registry";
4
+
5
+ const Component = () => <></>;
6
+
7
+ describe("client extension registry", () => {
8
+ test("registers each contribution and removes it on disposal", async () => {
9
+ const registry = new ClientExtensionRegistry();
10
+ const before = registry.snapshot();
11
+ let notifications = 0;
12
+ const unsubscribe = registry.subscribe(() => notifications++);
13
+ const loaded = await registry.load("example", (api) => {
14
+ api.registerSection({ id: "summary", zone: "threads.sidebar", title: "Summary", Component });
15
+ api.registerAction({ id: "open", zone: "thread.header", label: "Open", onPress() {} });
16
+ api.registerView({ id: "details", zone: "workspace.panels", title: "Details", Component });
17
+ });
18
+
19
+ expect(registry.snapshot()).not.toBe(before);
20
+ expect(registry.snapshot().sections.map(({ key }) => key)).toEqual(["example:summary"]);
21
+ expect(registry.snapshot().actions.map(({ key }) => key)).toEqual(["example:open"]);
22
+ expect(registry.snapshot().views.map(({ key }) => key)).toEqual(["example:details"]);
23
+ loaded.dispose();
24
+ unsubscribe();
25
+ expect(registry.snapshot().sections).toEqual([]);
26
+ expect(registry.snapshot().actions).toEqual([]);
27
+ expect(registry.snapshot().views).toEqual([]);
28
+ expect(notifications).toBe(6);
29
+ });
30
+
31
+ test("rolls back a factory that fails after registering", async () => {
32
+ const registry = new ClientExtensionRegistry();
33
+
34
+ await registry.load("broken", (api) => {
35
+ api.registerSection({ id: "summary", zone: "threads.sidebar", title: "Summary", Component });
36
+ throw new Error("boom");
37
+ });
38
+
39
+ expect(registry.snapshot().sections).toEqual([]);
40
+ expect(registry.errors[0]).toContain("boom");
41
+ });
42
+
43
+ test("a disposed registration cannot remove a later registration with the same ID", async () => {
44
+ const registry = new ClientExtensionRegistry();
45
+ let first: { dispose(): void } | undefined;
46
+
47
+ await registry.load("example", (api) => {
48
+ first = api.registerSection({
49
+ id: "summary",
50
+ zone: "threads.sidebar",
51
+ title: "Old",
52
+ Component,
53
+ });
54
+ first.dispose();
55
+ api.registerSection({ id: "summary", zone: "threads.sidebar", title: "New", Component });
56
+ });
57
+ first?.dispose();
58
+
59
+ expect(registry.snapshot().sections[0]?.value.title).toBe("New");
60
+ });
61
+ });