@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.
@@ -0,0 +1,159 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import * as React from "react";
3
+ import { useKeyboard } from "@opentui/react";
4
+ import * as v from "valibot";
5
+ import { discoverInstalledExtensionPackages } from "@cueloop/extension-api/installed-packages";
6
+ import type {
7
+ ClientExtensionAPI,
8
+ ClientExtensionFactory,
9
+ ExtensionUIDisposable,
10
+ ThreadHeaderAction,
11
+ ThreadSidebarSection,
12
+ WorkspacePanelView,
13
+ } from "@cueloop/extension-api/client";
14
+
15
+ interface OwnedContribution<T> {
16
+ key: string;
17
+ value: T;
18
+ }
19
+
20
+ /** A stable snapshot lets React subscribe without rebuilding contribution arrays on every render. */
21
+ export interface ClientExtensionSnapshot {
22
+ sections: readonly OwnedContribution<ThreadSidebarSection>[];
23
+ actions: readonly OwnedContribution<ThreadHeaderAction>[];
24
+ views: readonly OwnedContribution<WorkspacePanelView>[];
25
+ lastError: string | null;
26
+ }
27
+
28
+ /** Client extension registrations are isolated by package and disposed on load failure. */
29
+ export class ClientExtensionRegistry {
30
+ private readonly listeners = new Set<() => void>();
31
+ private readonly sections = new Map<string, ThreadSidebarSection>();
32
+ private readonly actions = new Map<string, ThreadHeaderAction>();
33
+ private readonly views = new Map<string, WorkspacePanelView>();
34
+ private current: ClientExtensionSnapshot = {
35
+ sections: [],
36
+ actions: [],
37
+ views: [],
38
+ lastError: null,
39
+ };
40
+ readonly errors: string[] = [];
41
+
42
+ readonly subscribe = (listener: () => void): (() => void) => {
43
+ this.listeners.add(listener);
44
+
45
+ return () => this.listeners.delete(listener);
46
+ };
47
+
48
+ readonly snapshot = (): ClientExtensionSnapshot => this.current;
49
+
50
+ private changed(): void {
51
+ this.current = {
52
+ sections: [...this.sections].map(([key, value]) => ({ key, value })),
53
+ actions: [...this.actions].map(([key, value]) => ({ key, value })),
54
+ views: [...this.views].map(([key, value]) => ({ key, value })),
55
+ lastError: this.errors.at(-1) ?? null,
56
+ };
57
+ for (const listener of this.listeners) listener();
58
+ }
59
+
60
+ private add<T extends { id: string; zone: string }>(
61
+ map: Map<string, T>,
62
+ owner: string,
63
+ contribution: T,
64
+ zone: string,
65
+ ): ExtensionUIDisposable {
66
+ if (!/^[a-z][a-z0-9-]*$/.test(contribution.id))
67
+ throw new Error(`Client extension invalid contribution ID: ${contribution.id}`);
68
+ if (contribution.zone !== zone)
69
+ throw new Error(`Client extension ${owner}:${contribution.id} must use zone ${zone}`);
70
+ const key = `${owner}:${contribution.id}`;
71
+
72
+ if (this.sections.has(key) || this.actions.has(key) || this.views.has(key))
73
+ throw new Error(`Client extension duplicate contribution: ${key}`);
74
+ map.set(key, contribution);
75
+ this.changed();
76
+
77
+ return {
78
+ dispose: () => {
79
+ if (map.get(key) === contribution) {
80
+ map.delete(key);
81
+ this.changed();
82
+ }
83
+ },
84
+ };
85
+ }
86
+
87
+ reportError(message: string): void {
88
+ this.errors.push(message);
89
+ this.changed();
90
+ }
91
+
92
+ /** Load one client factory and remove all of its registrations if it fails. */
93
+ async load(owner: string, factory: ClientExtensionFactory): Promise<ExtensionUIDisposable> {
94
+ const registrations: ExtensionUIDisposable[] = [];
95
+ const api: ClientExtensionAPI = {
96
+ react: React,
97
+ useKeyboard,
98
+ registerSection: (section) => {
99
+ const registration = this.add(this.sections, owner, section, "threads.sidebar");
100
+
101
+ registrations.push(registration);
102
+
103
+ return registration;
104
+ },
105
+ registerAction: (action) => {
106
+ const registration = this.add(this.actions, owner, action, "thread.header");
107
+
108
+ registrations.push(registration);
109
+
110
+ return registration;
111
+ },
112
+ registerView: (view) => {
113
+ const registration = this.add(this.views, owner, view, "workspace.panels");
114
+
115
+ registrations.push(registration);
116
+
117
+ return registration;
118
+ },
119
+ };
120
+
121
+ try {
122
+ await factory(api);
123
+ } catch (error) {
124
+ for (const registration of registrations) registration.dispose();
125
+ this.reportError(`Client extension ${owner}: ${String(error)}`);
126
+ }
127
+
128
+ return {
129
+ dispose: () => {
130
+ for (const registration of registrations) registration.dispose();
131
+ },
132
+ };
133
+ }
134
+ }
135
+
136
+ /** Import only the client entry points of installed extension packages. */
137
+ export async function loadInstalledClientExtensions(
138
+ registry: ClientExtensionRegistry,
139
+ installRoot?: string,
140
+ ): Promise<void> {
141
+ const discovery = discoverInstalledExtensionPackages(installRoot);
142
+
143
+ for (const error of discovery.errors) registry.reportError(error);
144
+ for (const extension of discovery.packages) {
145
+ if (!extension.clientEntry) continue;
146
+
147
+ try {
148
+ const module: unknown = await import(pathToFileURL(extension.clientEntry).href);
149
+ const parsed = v.safeParse(v.object({ default: v.function() }), module);
150
+
151
+ if (!parsed.success) throw new Error("client entry point must export a default factory");
152
+ await registry.load(extension.name, async (api) => {
153
+ await parsed.output.default(api);
154
+ });
155
+ } catch (error) {
156
+ registry.reportError(`Client extension ${extension.name}: ${String(error)}`);
157
+ }
158
+ }
159
+ }
@@ -0,0 +1,191 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import React from "react";
6
+ import { testRender } from "@opentui/react/test-utils";
7
+ import {
8
+ ClientExtensionRegistry,
9
+ loadInstalledClientExtensions,
10
+ } from "../client-extension-registry";
11
+ import { settle } from "../test-support";
12
+ import { AppShell } from "./AppShell";
13
+
14
+ function shell(
15
+ registry?: ClientExtensionRegistry,
16
+ onExtensionError?: (message: string) => void,
17
+ ): React.ReactNode {
18
+ return (
19
+ <AppShell
20
+ sidebarOpen
21
+ onToggleSidebar={() => {}}
22
+ onOpenMenu={() => {}}
23
+ threadsPanel={<text>threads list</text>}
24
+ threadTitle="Thread"
25
+ threadPanel={<text>thread body</text>}
26
+ changesOpen={false}
27
+ projectOpen
28
+ onToggleChanges={() => {}}
29
+ onToggleProject={() => {}}
30
+ onToggleRight={() => {}}
31
+ projectMode="tree"
32
+ focusedPane="project"
33
+ projectPanel={<text>project files</text>}
34
+ extensionRegistry={registry}
35
+ extensionContext={{ workspace: "/repo", threadId: "thread-1" }}
36
+ onExtensionError={onExtensionError}
37
+ />
38
+ );
39
+ }
40
+
41
+ describe("app shell extension zones", () => {
42
+ test("an empty registry preserves the built-in shell frame", async () => {
43
+ const plain = await testRender(shell(), { width: 100, height: 16 });
44
+ const extended = await testRender(shell(new ClientExtensionRegistry()), {
45
+ width: 100,
46
+ height: 16,
47
+ });
48
+
49
+ await settle(plain);
50
+ await settle(extended);
51
+ expect(extended.captureCharFrame()).toBe(plain.captureCharFrame());
52
+ plain.renderer.destroy();
53
+ extended.renderer.destroy();
54
+ });
55
+
56
+ test("renders a sidebar section and workspace tab without replacing built-in content", async () => {
57
+ const registry = new ClientExtensionRegistry();
58
+
59
+ await registry.load("example", (api) => {
60
+ api.registerSection({
61
+ id: "summary",
62
+ zone: "threads.sidebar",
63
+ title: "Summary",
64
+ Component: () => <text>extension summary</text>,
65
+ });
66
+ api.registerView({
67
+ id: "details",
68
+ zone: "workspace.panels",
69
+ title: "Details",
70
+ Component: () => <text>extension details</text>,
71
+ });
72
+ });
73
+ const setup = await testRender(shell(registry), { width: 100, height: 16 });
74
+
75
+ await settle(setup);
76
+ const frame = setup.captureCharFrame();
77
+
78
+ expect(frame).toContain("extension summary");
79
+ expect(frame).toContain("Details");
80
+ expect(frame).toContain("project files");
81
+ setup.mockInput.pressKey("]");
82
+ await settle(setup);
83
+ expect(setup.captureCharFrame()).toContain("extension details");
84
+ expect(setup.captureCharFrame()).not.toContain("project files");
85
+ setup.mockInput.pressKey("[");
86
+ await settle(setup);
87
+ expect(setup.captureCharFrame()).toContain("project files");
88
+ setup.renderer.destroy();
89
+ });
90
+
91
+ test("an installed client entry can use the host React hooks", async () => {
92
+ const root = mkdtempSync(join(tmpdir(), "cueloop-client-extension-test-"));
93
+ const packageRoot = join(root, "node_modules", "example-client");
94
+
95
+ try {
96
+ mkdirSync(packageRoot, { recursive: true });
97
+ writeFileSync(
98
+ join(root, "package.json"),
99
+ JSON.stringify({ dependencies: { "example-client": "1.0.0" } }),
100
+ );
101
+ writeFileSync(
102
+ join(packageRoot, "package.json"),
103
+ JSON.stringify({ cueloop: { client: "./client.js" } }),
104
+ );
105
+ writeFileSync(
106
+ join(packageRoot, "client.js"),
107
+ `export default (api) => {
108
+ const React = api.react;
109
+ api.registerSection({ id: "hook", zone: "threads.sidebar", title: "Host hook",
110
+ Component: () => { const [count] = React.useState(1); return React.createElement("text", null, String(count)); }
111
+ });
112
+ };`,
113
+ );
114
+ const registry = new ClientExtensionRegistry();
115
+
116
+ await loadInstalledClientExtensions(registry, root);
117
+ const setup = await testRender(shell(registry), { width: 100, height: 16 });
118
+
119
+ await settle(setup);
120
+ expect(registry.errors).toEqual([]);
121
+ expect(setup.captureCharFrame()).toContain("Host hook");
122
+ expect(setup.captureCharFrame()).toContain("1");
123
+ setup.renderer.destroy();
124
+ } finally {
125
+ rmSync(root, { recursive: true, force: true });
126
+ }
127
+ });
128
+
129
+ test("reports a failed client entry through the host error callback", async () => {
130
+ const registry = new ClientExtensionRegistry();
131
+ const errors: string[] = [];
132
+ const setup = await testRender(
133
+ shell(registry, (message) => errors.push(message)),
134
+ { width: 100, height: 16 },
135
+ );
136
+
137
+ registry.reportError("Client extension example: failed to load");
138
+ await settle(setup);
139
+ expect(errors).toEqual(["Client extension example: failed to load"]);
140
+ setup.renderer.destroy();
141
+ });
142
+
143
+ test("reports failed visibility callbacks for each UI zone", async () => {
144
+ const registry = new ClientExtensionRegistry();
145
+ const errors: string[] = [];
146
+ const when = () => {
147
+ throw new Error("visibility broke");
148
+ };
149
+
150
+ await registry.load("broken", (api) => {
151
+ api.registerSection({
152
+ id: "section",
153
+ zone: "threads.sidebar",
154
+ title: "Hidden section",
155
+ Component: () => <text>section body</text>,
156
+ when,
157
+ });
158
+ api.registerAction({
159
+ id: "action",
160
+ zone: "thread.header",
161
+ label: "Hidden action",
162
+ onPress() {},
163
+ when,
164
+ });
165
+ api.registerView({
166
+ id: "view",
167
+ zone: "workspace.panels",
168
+ title: "Hidden view",
169
+ Component: () => <text>view body</text>,
170
+ when,
171
+ });
172
+ });
173
+ const setup = await testRender(
174
+ shell(registry, (message) => errors.push(message)),
175
+ {
176
+ width: 100,
177
+ height: 16,
178
+ },
179
+ );
180
+
181
+ await settle(setup);
182
+ expect(errors).toEqual([
183
+ "Extension broken:section visibility failed: Error: visibility broke",
184
+ "Extension broken:action visibility failed: Error: visibility broke",
185
+ "Extension broken:view visibility failed: Error: visibility broke",
186
+ ]);
187
+ expect(setup.captureCharFrame()).not.toContain("Hidden section");
188
+ expect(setup.captureCharFrame()).not.toContain("Hidden view");
189
+ setup.renderer.destroy();
190
+ });
191
+ });
@@ -5,7 +5,7 @@
5
5
  // always present when the region is on, Changes rides on top of it, and a thin rail holds the sidebar
6
6
  // toggle when the region is closed. Each pane owns its own header controls; the thread header never does.
7
7
 
8
- import React, { useRef } from "react";
8
+ import React, { useEffect, useRef } from "react";
9
9
  import { useTerminalDimensions } from "@opentui/react";
10
10
  import type { BoxRenderable } from "@opentui/core";
11
11
  import { DARK, type Theme } from "../theme";
@@ -16,6 +16,28 @@ import { IconButton } from "./primitives/IconButton";
16
16
  import { NERD, HEADER_UNDERLINE_CHARS } from "./primitives/icons";
17
17
  import { TooltipProvider } from "./Tooltip";
18
18
  import { RootOverlayProvider } from "./RootOverlay";
19
+ import type { ExtensionUIContext } from "@cueloop/extension-api/client";
20
+ import { ClientExtensionRegistry } from "../client-extension-registry";
21
+ import { ExtensionZone, useClientExtensionSnapshot } from "./ExtensionZone";
22
+ import { WorkspacePanels } from "./WorkspacePanels";
23
+
24
+ const EMPTY_EXTENSION_REGISTRY = new ClientExtensionRegistry();
25
+ const EMPTY_EXTENSION_CONTEXT: ExtensionUIContext = { workspace: null, threadId: null };
26
+
27
+ interface ExtensionInputs {
28
+ registry: ClientExtensionRegistry;
29
+ context: ExtensionUIContext;
30
+ }
31
+
32
+ function extensionInputs(
33
+ registry?: ClientExtensionRegistry,
34
+ context?: ExtensionUIContext,
35
+ ): ExtensionInputs {
36
+ return {
37
+ registry: registry ?? EMPTY_EXTENSION_REGISTRY,
38
+ context: context ?? EMPTY_EXTENSION_CONTEXT,
39
+ };
40
+ }
19
41
 
20
42
  export type ProjectPanelMode = "changes" | "tree";
21
43
 
@@ -70,6 +92,10 @@ export interface AppShellProps {
70
92
  threadsWidth?: number;
71
93
  projectWidth?: number;
72
94
  onFocusPane?: (pane: FocusPane) => void;
95
+ focusedPane?: FocusPane;
96
+ extensionRegistry?: ClientExtensionRegistry;
97
+ extensionContext?: ExtensionUIContext;
98
+ onExtensionError?: (message: string) => void;
73
99
  }
74
100
 
75
101
  export type FocusPane = "threads" | "thread" | "changes" | "project";
@@ -126,7 +152,17 @@ export function AppShell({
126
152
  threadsWidth = 30,
127
153
  projectWidth = 32,
128
154
  onFocusPane,
155
+ focusedPane,
156
+ extensionRegistry,
157
+ extensionContext,
158
+ onExtensionError,
129
159
  }: AppShellProps): React.ReactNode {
160
+ const { registry, context } = extensionInputs(extensionRegistry, extensionContext);
161
+ const extensionSnapshot = useClientExtensionSnapshot(registry);
162
+ useEffect(() => {
163
+ if (extensionSnapshot.lastError) onExtensionError?.(extensionSnapshot.lastError);
164
+ }, [extensionSnapshot.lastError, onExtensionError]);
165
+
130
166
  const tokens = theme ?? DARK;
131
167
  const { width: terminalWidth } = useTerminalDimensions();
132
168
  // with both panels closed the right region collapses to nothing: the reopen toggle
@@ -165,31 +201,6 @@ export function AppShell({
165
201
  </box>
166
202
  );
167
203
 
168
- const projectToggles = (
169
- <box style={{ flexDirection: "row", flexShrink: 0, alignItems: "center" }}>
170
- <IconButton
171
- glyph="changes"
172
- active={projectMode === "changes"}
173
- onPress={onToggleChanges}
174
- marginRight={2}
175
- theme={tokens}
176
- />
177
- <IconButton
178
- glyph="project"
179
- active={projectMode === "tree"}
180
- onPress={onToggleProject}
181
- marginRight={2}
182
- theme={tokens}
183
- />
184
- <IconButton
185
- glyph={NERD.sidebarRight}
186
- onPress={onToggleRight}
187
- tip="Toggle Sidebar"
188
- theme={tokens}
189
- />
190
- </box>
191
- );
192
-
193
204
  // the reopen control - a divider tick then the toggle - shared by the collapsed
194
205
  // rail and, when the rail is suppressed, the thread header
195
206
  const reopenRightControl = (
@@ -226,6 +237,12 @@ export function AppShell({
226
237
  theme={tokens}
227
238
  >
228
239
  {threadsPanel}
240
+ <ExtensionZone
241
+ zone="threads.sidebar"
242
+ registry={registry}
243
+ context={context}
244
+ onError={onExtensionError}
245
+ />
229
246
  </PanelColumn>
230
247
  ) : null}
231
248
  {!zoomHideThread ? (
@@ -243,7 +260,19 @@ export function AppShell({
243
260
  }
244
261
  // with the region closed, the reopen toggle rides the thread header instead of an empty column
245
262
  headerRight={threadHeaderRight(
246
- threadActions,
263
+ extensionSnapshot.actions.length > 0 ? (
264
+ <box style={{ flexDirection: "row", alignItems: "center" }}>
265
+ {threadActions}
266
+ <ExtensionZone
267
+ zone="thread.header"
268
+ registry={registry}
269
+ context={context}
270
+ onError={onExtensionError}
271
+ />
272
+ </box>
273
+ ) : (
274
+ threadActions
275
+ ),
247
276
  reopenRightControl,
248
277
  rightRegionClosed,
249
278
  )}
@@ -273,16 +302,20 @@ export function AppShell({
273
302
  </box>
274
303
  ) : null}
275
304
  {projectOpen ? (
276
- <PanelColumn
305
+ <WorkspacePanels
277
306
  width={projectWidth}
278
- border="left"
279
- header={null}
280
- headerRight={projectToggles}
307
+ projectMode={projectMode}
308
+ projectPanel={projectPanel}
309
+ onToggleChanges={onToggleChanges}
310
+ onToggleProject={onToggleProject}
311
+ onToggleRight={onToggleRight}
281
312
  onFocus={() => onFocusPane?.("project")}
313
+ focused={focusedPane === "project"}
314
+ registry={registry}
315
+ context={context}
282
316
  theme={tokens}
283
- >
284
- {projectPanel}
285
- </PanelColumn>
317
+ onExtensionError={onExtensionError}
318
+ />
286
319
  ) : rightRegionClosed ? null : (
287
320
  // Changes open but Project closed: a divider tick above the continuous header underline holds the reopen toggle;
288
321
  // the rule lives in the header only, never running the pane's full height. The extra column
@@ -0,0 +1,28 @@
1
+ import React from "react";
2
+ import { ClientExtensionRegistry } from "../client-extension-registry";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { ExtensionZone } from "./ExtensionZone";
5
+
6
+ export const meta: StoryMeta = { title: "Extensions/ExtensionZone" };
7
+
8
+ const registry = new ClientExtensionRegistry();
9
+
10
+ void registry.load("example", (api) => {
11
+ api.registerSection({
12
+ id: "summary",
13
+ zone: "threads.sidebar",
14
+ title: "Summary",
15
+ Component: () => <text>Two pending Threads</text>,
16
+ });
17
+ });
18
+
19
+ export const SidebarSection: Story = {
20
+ render: () => (
21
+ <ExtensionZone
22
+ zone="threads.sidebar"
23
+ registry={registry}
24
+ context={{ workspace: "/repo", threadId: null }}
25
+ />
26
+ ),
27
+ size: { width: 32, height: 8 },
28
+ };
@@ -0,0 +1,145 @@
1
+ import React, { useEffect, useMemo, useSyncExternalStore } from "react";
2
+ import type { ExtensionUIContext } from "@cueloop/extension-api/client";
3
+ import type {
4
+ ClientExtensionRegistry,
5
+ ClientExtensionSnapshot,
6
+ } from "../client-extension-registry";
7
+ import { useComponentTheme } from "./theme-context";
8
+ import { IconButton } from "./primitives/IconButton";
9
+ import { NERD } from "./primitives/icons";
10
+
11
+ /** Subscribe to the client extension registry without coupling extensions to React state. */
12
+ export function useClientExtensionSnapshot(
13
+ registry: ClientExtensionRegistry,
14
+ ): ClientExtensionSnapshot {
15
+ return useSyncExternalStore(registry.subscribe, registry.snapshot, registry.snapshot);
16
+ }
17
+
18
+ interface VisibleContributions<T> {
19
+ items: T[];
20
+ errors: string[];
21
+ }
22
+
23
+ /** Evaluate visibility without letting one extension remove another's UI. */
24
+ export function visibleContributions<
25
+ T extends {
26
+ key: string;
27
+ value: { when?: (context: ExtensionUIContext) => boolean };
28
+ },
29
+ >(contributions: readonly T[], context: ExtensionUIContext): VisibleContributions<T> {
30
+ const items: T[] = [];
31
+ const errors: string[] = [];
32
+
33
+ for (const contribution of contributions) {
34
+ try {
35
+ if (!contribution.value.when || contribution.value.when(context)) items.push(contribution);
36
+ } catch (error) {
37
+ errors.push(`Extension ${contribution.key} visibility failed: ${String(error)}`);
38
+ }
39
+ }
40
+
41
+ return { items, errors };
42
+ }
43
+
44
+ type VisibilityContribution = {
45
+ key: string;
46
+ value: { when?: (context: ExtensionUIContext) => boolean };
47
+ };
48
+
49
+ class ExtensionRenderBoundary extends React.Component<
50
+ { children: React.ReactNode; onError?: (message: string) => void },
51
+ { failed: boolean }
52
+ > {
53
+ state = { failed: false };
54
+
55
+ static getDerivedStateFromError() {
56
+ return { failed: true };
57
+ }
58
+
59
+ componentDidCatch(error: Error): void {
60
+ console.error("Client extension rendering failed", error);
61
+ this.props.onError?.(`Extension rendering failed: ${error.message}`);
62
+ }
63
+
64
+ render(): React.ReactNode {
65
+ if (this.state.failed) return <text>Extension failed</text>;
66
+
67
+ return this.props.children;
68
+ }
69
+ }
70
+
71
+ /** Render host-framed sections, host buttons, or the selected workspace view. */
72
+ export function ExtensionZone(props: {
73
+ zone: "threads.sidebar" | "thread.header" | "workspace.panels";
74
+ registry: ClientExtensionRegistry;
75
+ context: ExtensionUIContext;
76
+ selectedViewId?: string | null;
77
+ onError?: (message: string) => void;
78
+ }): React.ReactNode {
79
+ const { zone, registry, context, selectedViewId, onError } = props;
80
+ const snapshot = useClientExtensionSnapshot(registry);
81
+ const theme = useComponentTheme();
82
+ const { workspace, threadId } = context;
83
+ const visibility = useMemo(() => {
84
+ const contributions: readonly VisibilityContribution[] =
85
+ zone === "threads.sidebar"
86
+ ? snapshot.sections
87
+ : zone === "thread.header"
88
+ ? snapshot.actions
89
+ : [];
90
+
91
+ return visibleContributions(contributions, { workspace, threadId });
92
+ }, [zone, snapshot, workspace, threadId]);
93
+
94
+ useEffect(() => {
95
+ for (const error of visibility.errors) onError?.(error);
96
+ }, [visibility, onError]);
97
+
98
+ const visibleKeys = new Set(visibility.items.map(({ key }) => key));
99
+
100
+ if (zone === "threads.sidebar") {
101
+ return snapshot.sections
102
+ .filter(({ key }) => visibleKeys.has(key))
103
+ .map(({ key, value }) => (
104
+ <box key={key} style={{ flexDirection: "column", maxHeight: 8, flexShrink: 0 }}>
105
+ <text fg={theme.textDim}>{value.title}</text>
106
+ <ExtensionRenderBoundary onError={onError}>
107
+ {React.createElement(value.Component, { context })}
108
+ </ExtensionRenderBoundary>
109
+ </box>
110
+ ));
111
+ }
112
+
113
+ if (zone === "thread.header") {
114
+ return snapshot.actions
115
+ .filter(({ key }) => visibleKeys.has(key))
116
+ .map(({ key, value }) => (
117
+ <IconButton
118
+ key={key}
119
+ glyph={value.glyph ?? NERD.kebab}
120
+ tip={value.label}
121
+ onPress={() => {
122
+ try {
123
+ void Promise.resolve(value.onPress(context)).catch((error) => {
124
+ console.error(`Client extension action failed: ${key}`, error);
125
+ onError?.(`Extension action failed: ${value.label}`);
126
+ });
127
+ } catch (error) {
128
+ console.error(`Client extension action failed: ${key}`, error);
129
+ onError?.(`Extension action failed: ${value.label}`);
130
+ }
131
+ }}
132
+ marginRight={1}
133
+ theme={theme}
134
+ />
135
+ ));
136
+ }
137
+
138
+ const selected = snapshot.views.find(({ key }) => key === selectedViewId);
139
+
140
+ return selected ? (
141
+ <ExtensionRenderBoundary key={selected.key} onError={onError}>
142
+ {React.createElement(selected.value.Component, { context })}
143
+ </ExtensionRenderBoundary>
144
+ ) : null;
145
+ }