@xpufx/paseo-top 0.4.2 → 0.4.3

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # paseo-top
2
2
 
3
- Live host system resource monitor and telemetry provider for [Paseo](https://github.com/getpaseo/paseo) (v0.8+).
3
+ Live host system resource monitor and telemetry provider for [Paseo](https://github.com/getpaseo/paseo).
4
4
 
5
5
  Displays real-time host metrics, session metadata, turn telemetry, and custom pill widgets directly in the composer track bar without cluttering the interface. Automatically stamps performance summaries into agent conversation timelines and provides an interactive modal dashboard with live gauges.
6
6
 
@@ -12,11 +12,11 @@ Built on [paseo-plugin-helper](https://github.com/xpufx/paseo/tree/main/packages
12
12
 
13
13
  | General Composer View | Dashboard View |
14
14
  | :---: | :---: |
15
- | [![General Composer View](screenshots/paseo-top-general-view.jpg)](screenshots/paseo-top-general-view.jpg) | [![Dashboard View](screenshots/paseo-top-dashboard.jpg)](screenshots/paseo-top-dashboard.jpg) |
15
+ | [![General Composer View](https://raw.githubusercontent.com/xpufx/paseo/main/plugins/top/screenshots/paseo-top-general-view.jpg)](https://raw.githubusercontent.com/xpufx/paseo/main/plugins/top/screenshots/paseo-top-general-view.jpg) | [![Dashboard View](https://raw.githubusercontent.com/xpufx/paseo/main/plugins/top/screenshots/paseo-top-dashboard.jpg)](https://raw.githubusercontent.com/xpufx/paseo/main/plugins/top/screenshots/paseo-top-dashboard.jpg) |
16
16
 
17
17
  | System Resources Modal | Workspace Context Modal | Settings Modal | About Modal |
18
18
  | :---: | :---: | :---: | :---: |
19
- | <img src="screenshots/1-paseo-top-modal-system.jpg" alt="System Resources Modal" width="100%" /> | <img src="screenshots/2-paseo-top-modal-workspace.jpg" alt="Workspace Context Modal" width="100%" /> | <img src="screenshots/3-paseo-top-modal-settings.jpg" alt="Settings Modal" width="100%" /> | <img src="screenshots/4-paseo-top-modal-about.jpg" alt="About Modal" width="100%" /> |
19
+ | <img src="https://raw.githubusercontent.com/xpufx/paseo/main/plugins/top/screenshots/1-paseo-top-modal-system.jpg" alt="System Resources Modal" width="100%" /> | <img src="https://raw.githubusercontent.com/xpufx/paseo/main/plugins/top/screenshots/2-paseo-top-modal-workspace.jpg" alt="Workspace Context Modal" width="100%" /> | <img src="https://raw.githubusercontent.com/xpufx/paseo/main/plugins/top/screenshots/3-paseo-top-modal-settings.jpg" alt="Settings Modal" width="100%" /> | <img src="https://raw.githubusercontent.com/xpufx/paseo/main/plugins/top/screenshots/4-paseo-top-modal-about.jpg" alt="About Modal" width="100%" /> |
20
20
 
21
21
 
22
22
  ## Core Capabilities
@@ -45,6 +45,7 @@ Built on [paseo-plugin-helper](https://github.com/xpufx/paseo/tree/main/packages
45
45
 
46
46
  ### 4. Fleet & MCP Integration
47
47
  - **MCP Server Health**: Bridges with `mcp-tools` to monitor connected MCP servers, distinguishing healthy, degraded, or offline servers alongside latency figures.
48
+ - **Multi-Host Fleet View**: The dashboard **Fleet** tab enumerates hosts from `useHosts()` and shows one card per host with a status badge (`online` / `stale` / `offline` / `error`), probe latency, and per-host counts. Counts are summed across responsive hosts; CPU/RAM/load stay per-host and are never aggregated. Polling is bounded to one in-flight probe per host on a 15s cadence with a 4s per-host timeout that marks the host `stale` until the next tick. No remote shell or custom-command execution is performed.
48
49
 
49
50
  ### 5. Flexible Display Modes
50
51
  - **Cycle Mode**: Automatically cycles through enabled metric segments on each refresh interval.
@@ -75,24 +76,22 @@ Add user-defined metrics by dropping declarative `.jsonc` definitions into `~/.p
75
76
 
76
77
  ### 8. Native Modal Dashboard
77
78
  Clicking the pill opens a native dashboard with:
78
- - **Pinned Header Navbar**: Fixed tabs for System Resources, Activity Timeline, Custom Pills, Settings, and About. Content smoothly scrolls beneath the navbar.
79
+ - **Pinned Header Navbar**: Fixed tabs for System Resources, Fleet, Activity Timeline, Custom Pills, Settings, and About. Content smoothly scrolls beneath the navbar.
79
80
  - **Visual Gauges**: Responsive bars showing CPU and RAM distribution.
80
81
  - **Full Settings Suite**: Live toggles for every metric and pill, with persistent state managed by `PluginStorage`.
81
82
 
82
83
  ## Installation
83
84
 
84
- Install using the native Paseo monorepo subpath syntax:
85
+ Install from npm:
85
86
 
86
- ```bash
87
- paseo plugin add xpufx/paseo --path plugins/top
87
+ ```sh
88
+ paseo plugin add npm:@xpufx/paseo-top
88
89
  ```
89
90
 
90
- For local development:
91
+ Or install directly from the Git repository:
91
92
 
92
- ```bash
93
- git clone git@github.com:xpufx/paseo.git
94
- cd paseo
95
- paseo plugin add ./plugins/top
93
+ ```sh
94
+ paseo plugin add xpufx/paseo --path plugins/top
96
95
  ```
97
96
 
98
97
  ## Development
@@ -0,0 +1,177 @@
1
+ import React, { useMemo } from "react";
2
+ import { Text } from "react-native";
3
+ import {
4
+ Badge,
5
+ Card,
6
+ CardHeader,
7
+ EmptyState,
8
+ KeyValue,
9
+ KeyValueGroup,
10
+ Row,
11
+ SectionHeader,
12
+ Stack,
13
+ StatusDot,
14
+ usePluginTheme,
15
+ } from "./vendor/paseo-plugin-helper/index";
16
+ import {
17
+ aggregateFleet,
18
+ type FleetHostSnapshot,
19
+ type FleetHostStatus,
20
+ } from "../shared/multi-host";
21
+ import { useFleetPolling } from "./multi-host";
22
+
23
+ const STATUS_VARIANT: Record<
24
+ FleetHostStatus,
25
+ "success" | "warning" | "neutral" | "danger"
26
+ > = {
27
+ online: "success",
28
+ stale: "warning",
29
+ offline: "neutral",
30
+ error: "danger",
31
+ };
32
+
33
+ const STATUS_LABEL: Record<FleetHostStatus, string> = {
34
+ online: "online",
35
+ stale: "stale",
36
+ offline: "offline",
37
+ error: "error",
38
+ };
39
+
40
+ function formatLatency(latencyMs: number | null): string {
41
+ if (latencyMs === null) return "--";
42
+ if (latencyMs < 1000) return `${Math.round(latencyMs)} ms`;
43
+ return `${(latencyMs / 1000).toFixed(2)} s`;
44
+ }
45
+
46
+ function formatStaleAge(
47
+ staleAt: number | null,
48
+ now: number,
49
+ ): string | null {
50
+ if (staleAt === null) return null;
51
+ const seconds = Math.max(0, Math.round((now - staleAt) / 1000));
52
+ return `stale ${seconds}s`;
53
+ }
54
+
55
+ function FleetHostCard({
56
+ snapshot,
57
+ now,
58
+ }: {
59
+ snapshot: FleetHostSnapshot;
60
+ now: number;
61
+ }) {
62
+ const { colors } = usePluginTheme();
63
+ const variant = STATUS_VARIANT[snapshot.status];
64
+ const staleAge = formatStaleAge(snapshot.staleAt, now);
65
+ const subtitle =
66
+ snapshot.status === "online"
67
+ ? "Connected"
68
+ : (staleAge ?? snapshot.error ?? "Not connected");
69
+
70
+ return (
71
+ <Card variant="elevated">
72
+ <CardHeader
73
+ icon="Server"
74
+ title={snapshot.label}
75
+ subtitle={subtitle}
76
+ badge={
77
+ <Row gap={6} align="center">
78
+ <StatusDot
79
+ variant={snapshot.status === "online" ? "success" : variant}
80
+ pulse={snapshot.status === "online"}
81
+ />
82
+ <Badge
83
+ label={STATUS_LABEL[snapshot.status]}
84
+ variant={variant}
85
+ size="sm"
86
+ />
87
+ </Row>
88
+ }
89
+ />
90
+ <KeyValueGroup>
91
+ <KeyValue label="Host" value={snapshot.serverId} truncate="middle" copyable />
92
+ <KeyValue label="Latency" value={formatLatency(snapshot.latencyMs)} />
93
+ <KeyValue
94
+ label="Agents"
95
+ value={snapshot.counts ? String(snapshot.counts.agents) : "--"}
96
+ />
97
+ <KeyValue
98
+ label="Active agents"
99
+ value={snapshot.counts ? String(snapshot.counts.activeAgents) : "--"}
100
+ />
101
+ <KeyValue
102
+ label="Workspaces"
103
+ value={snapshot.counts ? String(snapshot.counts.workspaces) : "--"}
104
+ />
105
+ </KeyValueGroup>
106
+ {snapshot.status === "error" && snapshot.error ? (
107
+ <Text style={{ marginTop: 8, fontSize: 11, color: colors.statusDanger }}>
108
+ {snapshot.error}
109
+ </Text>
110
+ ) : null}
111
+ </Card>
112
+ );
113
+ }
114
+
115
+ /**
116
+ * Multi-host Fleet view. Renders one card per configured host with a status
117
+ * badge and per-host counts, plus a counts-only aggregate. Local Top gauges
118
+ * (CPU/RAM) stay in the Activity tab and are never summed or averaged here.
119
+ *
120
+ * Mounting is the lifecycle: this component is rendered only while the Fleet
121
+ * tab is active, so it starts polling on mount and disposes on unmount. Local
122
+ * Top behavior is unaffected whenever the tab is not selected.
123
+ */
124
+ export function FleetView() {
125
+ const { colors } = usePluginTheme();
126
+ const snapshots = useFleetPolling();
127
+ const now = Date.now();
128
+ const aggregate = useMemo(() => aggregateFleet(snapshots), [snapshots]);
129
+
130
+ if (snapshots.length === 0) {
131
+ return (
132
+ <EmptyState
133
+ icon="Server"
134
+ title="No other hosts"
135
+ description="Configure additional hosts in Paseo to see their fleet status here."
136
+ />
137
+ );
138
+ }
139
+
140
+ return (
141
+ <Stack gap={12}>
142
+ <Text style={{ fontSize: 16, fontWeight: "700", color: colors.foreground }}>
143
+ Multi-Host Fleet
144
+ </Text>
145
+
146
+ <Card variant="elevated">
147
+ <CardHeader
148
+ title="Fleet Totals"
149
+ icon="Activity"
150
+ subtitle="Counts only. CPU/RAM/load stay per-host."
151
+ />
152
+ <KeyValueGroup>
153
+ <KeyValue label="Hosts" value={`${aggregate.responsive}/${aggregate.hosts} responsive`} />
154
+ <KeyValue
155
+ label="Online / Stale"
156
+ value={`${aggregate.online} / ${aggregate.stale}`}
157
+ />
158
+ <KeyValue label="Agents" value={String(aggregate.counts.agents)} />
159
+ <KeyValue
160
+ label="Active agents"
161
+ value={String(aggregate.counts.activeAgents)}
162
+ />
163
+ <KeyValue label="Workspaces" value={String(aggregate.counts.workspaces)} />
164
+ </KeyValueGroup>
165
+ </Card>
166
+
167
+ <SectionHeader title="Hosts" count={snapshots.length} />
168
+ {snapshots.map((snapshot) => (
169
+ <FleetHostCard
170
+ key={snapshot.serverId}
171
+ snapshot={snapshot}
172
+ now={now}
173
+ />
174
+ ))}
175
+ </Stack>
176
+ );
177
+ }
@@ -0,0 +1,98 @@
1
+ import { getPaseoClient, useHosts } from "@getpaseo/plugin/client";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
+ import {
4
+ MultiHostPoller,
5
+ type FleetHostClient,
6
+ type FleetHostCounts,
7
+ type FleetHostSnapshot,
8
+ } from "../shared/multi-host";
9
+
10
+ /**
11
+ * Wrap a borrowed `getPaseoClient(serverId)` as a fleet client.
12
+ *
13
+ * Only counts are read. The borrowed API exposes agents/workspaces/projects/
14
+ * terminals/providers/config and no host-metrics RPC, so the fleet view cannot
15
+ * show remote CPU/RAM/load/uptime; fabricating them would be mock data.
16
+ */
17
+ export function createFleetClient(serverId: string): FleetHostClient {
18
+ // getPaseoClient throws for a non-online/unknown host; the poller only
19
+ // acquires for online hosts, and the throw is caught per host so one bad
20
+ // host cannot break the others.
21
+ const client = getPaseoClient(serverId);
22
+ return {
23
+ async readCounts(): Promise<FleetHostCounts> {
24
+ const [agents, workspaces] = await Promise.all([
25
+ client.agents.list(),
26
+ client.workspaces.list(),
27
+ ]);
28
+ const entries = agents.entries ?? [];
29
+ return {
30
+ agents: entries.length,
31
+ activeAgents: entries.filter((entry) => entry.agent?.status === "running")
32
+ .length,
33
+ workspaces: workspaces.entries?.length ?? 0,
34
+ };
35
+ },
36
+ dispose() {
37
+ return client.dispose();
38
+ },
39
+ };
40
+ }
41
+
42
+ export interface UseFleetPollingOptions {
43
+ /** Poll only while the fleet view is visible; keeps local-only paths idle. */
44
+ enabled?: boolean;
45
+ }
46
+
47
+ /**
48
+ * Bounded multi-host fleet polling hook.
49
+ *
50
+ * Enumerates online hosts with `useHosts()`, acquires a borrowed
51
+ * `getPaseoClient(serverId)` per online host, and drives the pure
52
+ * {@link MultiHostPoller} state machine: max one in-flight probe per host,
53
+ * 15s cadence, 4s per-host timeout, stale marking, and dispose/reacquire on
54
+ * host status transitions. Local Top behavior is untouched because nothing
55
+ * starts unless the fleet view is mounted and enabled.
56
+ */
57
+ export function useFleetPolling({ enabled = true }: UseFleetPollingOptions = {}) {
58
+ const hosts = useHosts();
59
+ const [snapshots, setSnapshots] = useState<FleetHostSnapshot[]>([]);
60
+ const pollerRef = useRef<MultiHostPoller | null>(null);
61
+
62
+ const hostInputs = useMemo(
63
+ () =>
64
+ hosts.map((host) => ({
65
+ serverId: host.serverId,
66
+ label: host.label,
67
+ status: host.status,
68
+ })),
69
+ [hosts],
70
+ );
71
+
72
+ // Keep the latest host list in a ref so the create-once effect can seed from
73
+ // it without re-subscribing on every status change.
74
+ const hostInputsRef = useRef(hostInputs);
75
+ hostInputsRef.current = hostInputs;
76
+
77
+ useEffect(() => {
78
+ if (!enabled) return;
79
+ const poller = new MultiHostPoller({
80
+ acquire: createFleetClient,
81
+ onUpdate: setSnapshots,
82
+ });
83
+ pollerRef.current = poller;
84
+ poller.setHosts(hostInputsRef.current);
85
+ poller.start();
86
+ return () => {
87
+ poller.dispose();
88
+ pollerRef.current = null;
89
+ setSnapshots([]);
90
+ };
91
+ }, [enabled]);
92
+
93
+ useEffect(() => {
94
+ pollerRef.current?.setHosts(hostInputs);
95
+ }, [hostInputs]);
96
+
97
+ return snapshots;
98
+ }
package/client/pill.tsx CHANGED
@@ -1111,6 +1111,7 @@ function ResourceModal({ theme, workspaceId, agentId, initialTab, payload }: Res
1111
1111
  <ModalBody
1112
1112
  style={{ backgroundColor: colors.surface0 }}
1113
1113
  headerMode="pinned"
1114
+ scrollMode="always"
1114
1115
  refreshing={isRefetching}
1115
1116
  onRefresh={handleRefresh}
1116
1117
  header={navbar}
@@ -1135,6 +1136,7 @@ function ResourceModal({ theme, workspaceId, agentId, initialTab, payload }: Res
1135
1136
  <ModalBody
1136
1137
  style={{ backgroundColor: colors.surface0 }}
1137
1138
  headerMode="pinned"
1139
+ scrollMode="always"
1138
1140
  refreshing={isLoading || isRefetching}
1139
1141
  onRefresh={handleRefresh}
1140
1142
  header={navbar}
@@ -30,11 +30,13 @@ import { PLUGIN_VERSION } from "../shared/version";
30
30
  import { notifySettingsChanged } from "./pill";
31
31
  import { ChoiceChips } from "./settings-ui";
32
32
  import { useTopResourceQuery } from "./resources-query";
33
+ import { FleetView } from "./multi-host-view";
33
34
 
34
- type SurfaceTab = "system" | "settings" | "about";
35
+ type SurfaceTab = "system" | "fleet" | "settings" | "about";
35
36
 
36
37
  const TABS = [
37
38
  { id: "system", label: "Activity", shortLabel: "Activity", icon: "Activity" },
39
+ { id: "fleet", label: "Fleet", shortLabel: "Fleet", icon: "Server" },
38
40
  { id: "settings", label: "Settings", shortLabel: "Settings", icon: "Sliders" },
39
41
  { id: "about", label: "About", shortLabel: "About", icon: "Info" },
40
42
  ];
@@ -47,12 +49,6 @@ const TIMELINE_CADENCE_OPTIONS = Array.from({ length: 11 }, (_, n) => ({
47
49
  const CPU_THRESHOLDS = { warning: 60, danger: 85 };
48
50
  const MEM_THRESHOLDS = { warning: 70, danger: 85 };
49
51
 
50
- // Keep the dashboard a readable centered column instead of stretching
51
- // edge-to-edge on large viewports. The cap lives in the helper
52
- // (`ModalBody maxContentWidth`); this is the only place the surface picks
53
- // the value.
54
- const TOP_CONTENT_MAX_WIDTH = 600;
55
-
56
52
  export function TopDashboardSurface(_props: PluginSurfaceProps) {
57
53
  const { colors } = usePluginTheme();
58
54
  const [activeTab, setActiveTab] = useState<SurfaceTab>("system");
@@ -83,7 +79,6 @@ export function TopDashboardSurface(_props: PluginSurfaceProps) {
83
79
  <View style={[styles.root, { backgroundColor: colors.surface0 }]}>
84
80
  <ModalBody
85
81
  headerMode="pinned"
86
- maxContentWidth={TOP_CONTENT_MAX_WIDTH}
87
82
  header={
88
83
  <Tabs
89
84
  tabs={TABS}
@@ -177,6 +172,8 @@ export function TopDashboardSurface(_props: PluginSurfaceProps) {
177
172
  </Stack>
178
173
  )}
179
174
 
175
+ {activeTab === "fleet" && <FleetView />}
176
+
180
177
  {activeTab === "settings" && (
181
178
  <Stack gap={12}>
182
179
  <Card variant="elevated">
@@ -33,6 +33,7 @@ export function resolveButtonAttentionTone(variant: ButtonVariant): AttentionBea
33
33
 
34
34
  export interface ButtonProps {
35
35
  label?: string;
36
+ children?: ReactNode;
36
37
  variant?: ButtonVariant;
37
38
  size?: ButtonSize;
38
39
  icon?: string | ReactNode;
@@ -43,11 +44,13 @@ export interface ButtonProps {
43
44
  style?: StyleProp<ViewStyle>;
44
45
  textStyle?: StyleProp<TextStyle>;
45
46
  accessibilityLabel?: string;
47
+ accessibilityRole?: "button" | "link";
46
48
  attention?: ButtonAttention;
47
49
  }
48
50
 
49
51
  export function Button({
50
52
  label,
53
+ children,
51
54
  variant = "secondary",
52
55
  size = "md",
53
56
  icon,
@@ -58,6 +61,7 @@ export function Button({
58
61
  style,
59
62
  textStyle,
60
63
  accessibilityLabel,
64
+ accessibilityRole = "button",
61
65
  attention,
62
66
  }: ButtonProps) {
63
67
  const { Icon } = getClientHost();
@@ -112,7 +116,7 @@ export function Button({
112
116
  <Pressable
113
117
  onPress={onPress}
114
118
  disabled={disabled || loading}
115
- accessibilityRole="button"
119
+ accessibilityRole={accessibilityRole}
116
120
  accessibilityLabel={accessibilityLabel || label}
117
121
  hitSlop={Math.max(0, (touchTargetMin - 32) / 2)}
118
122
  style={({ pressed }) => [
@@ -132,6 +136,8 @@ export function Button({
132
136
  >
133
137
  {loading ? (
134
138
  <ActivityIndicator size="small" color={textColor} />
139
+ ) : children ? (
140
+ children
135
141
  ) : (
136
142
  <>
137
143
  {iconPosition === "left" && renderIcon()}
@@ -16,6 +16,7 @@ export interface InlineButtonProps {
16
16
  icon?: string | ReactNode;
17
17
  disabled?: boolean;
18
18
  accessibilityLabel?: string;
19
+ accessibilityRole?: "button" | "link";
19
20
  style?: StyleProp<ViewStyle>;
20
21
  textStyle?: StyleProp<TextStyle>;
21
22
  }
@@ -27,6 +28,7 @@ export function InlineButton({
27
28
  icon,
28
29
  disabled = false,
29
30
  accessibilityLabel,
31
+ accessibilityRole = "button",
30
32
  style,
31
33
  textStyle,
32
34
  }: InlineButtonProps) {
@@ -37,7 +39,7 @@ export function InlineButton({
37
39
  <Pressable
38
40
  onPress={onPress}
39
41
  disabled={disabled}
40
- accessibilityRole="button"
42
+ accessibilityRole={accessibilityRole}
41
43
  accessibilityLabel={accessibilityLabel || label}
42
44
  hitSlop={Math.max(0, (touchTargetMin - 24) / 2)}
43
45
  style={({ pressed }) => [
@@ -1,4 +1,5 @@
1
1
  export * from "./theme/index";
2
+ export * from "./styles/index";
2
3
  export * from "./components/index";
3
4
  export * from "./layout/index";
4
5
  export * from "./pill";
@@ -88,7 +88,7 @@ export interface ModalBodyProps {
88
88
  * Adding the `"required"` member is additive: the existing two values keep
89
89
  * their meaning and the default stays `"helper"`.
90
90
  */
91
- export type ModalBodyScrollOwner = "helper" | "host" | "required";
91
+ export type ModalBodyScrollOwner = "helper" | "host" | "required" | "popover";
92
92
 
93
93
  export const ModalBodyScrollOwnerContext = createContext<ModalBodyScrollOwner>("helper");
94
94
 
@@ -154,7 +154,7 @@ export function ModalBody({
154
154
  }: ModalBodyProps) {
155
155
  const { isCompact, isMobile, layout, padding, colors } = usePluginTheme();
156
156
  const scrollOwner = useContext(ModalBodyScrollOwnerContext);
157
- const hostOwnsScroll = scrollOwner === "host";
157
+ const hostOwnsScroll = scrollOwner === "host" || scrollOwner === "popover";
158
158
  // "required": an ancestor proved the host supplies no scroller (plugin
159
159
  // surface) — own the scroll on every surface, including non-compact desktop.
160
160
  const helperOwnsScroll =
@@ -165,7 +165,7 @@ export function ModalBody({
165
165
  // already full-bleed, and a composer popover viewport is host-owned and
166
166
  // intentionally narrow, so both ignore the preset.
167
167
  const sizeStyle =
168
- size === "large" && !isMobile && !isCompact && !hostOwnsScroll
168
+ size === "large" && !isMobile && !isCompact && scrollOwner !== "popover"
169
169
  ? styles.largeDialog
170
170
  : undefined;
171
171
  const ResolvedScrollView = selectHostScrollView(
@@ -1,35 +1,33 @@
1
1
  import React, { type ReactNode } from "react";
2
2
  import { getClientHost } from "../host";
3
- import { ModalBody, type ModalBodyProps } from "./ModalBody";
3
+ import { ModalBody, ModalBodyScrollOwnerContext, type ModalBodyProps } from "./ModalBody";
4
4
 
5
5
  export interface ModalContentProps extends Omit<ModalBodyProps, "scrollMode"> {
6
6
  children: ReactNode;
7
+ /**
8
+ * Whether the host owns the outer scroll container.
9
+ * Defaults to `true` so host desktop mouse wheel, trackpad, and mobile bottom
10
+ * sheet gestures scroll natively without fighting an inner scroller.
11
+ * Set to `false` only if the modal contains a custom internal scroller
12
+ * (e.g. virtualized list or canvas).
13
+ */
14
+ scrollable?: boolean;
7
15
  }
8
16
 
9
17
  /**
10
18
  * Helper-owned modal body for plugins that open their own host `<Modal>`.
11
19
  *
12
- * Use this instead of the raw host `<Modal.Content>`: it wraps the host content
13
- * view AND the shared `ModalBody` contract in one element, so a plugin cannot
14
- * accidentally end up content-sized.
20
+ * Delegates scroll ownership to the host `<Modal.Content>` (scrollable by default)
21
+ * so desktop mouse wheel, trackpad, and mobile bottom sheet gestures scroll
22
+ * natively under Paseo host rules without fighting an inner scroller.
15
23
  *
16
- * Why the raw host `Modal.Content` resizes: Paseo maps
17
- * `<Modal.Content scrollable={true}>` (the host default) to a desktop card with
18
- * no explicit height, so the dialog grows/shrinks with its children on every
19
- * data change. This wrapper always passes `scrollable={false}`, which makes the
20
- * host allocate a bounded dialog (`desktopHeight: "85%"`). Because that host
21
- * content view then supplies no scroller, the wrapper also forces
22
- * `ModalBody scrollMode="always"`, so the helper owns the one scroll region on
23
- * every surface and the bounded dialog scrolls instead of clipping.
24
- *
25
- * The size contract is `ModalBody`'s: it takes the host-allocated dialog size
26
- * and is fluid within it; `size?: "default" | "large"` is the only size escape
27
- * hatch. Do not add per-plugin width/minWidth/height literals around it.
24
+ * Sizing stays fluid within the host-allocated modal frame, respecting
25
+ * `size?: "default" | "large"`.
28
26
  *
29
27
  * ```tsx
30
28
  * <Modal title="…" open={open} onOpenChange={setOpen}>
31
29
  * <ModalContent size="default">
32
- * …cards, controls, rows; content never drives the dialog frame…
30
+ * …cards, controls, rows…
33
31
  * </ModalContent>
34
32
  * </Modal>
35
33
  * ```
@@ -37,13 +35,17 @@ export interface ModalContentProps extends Omit<ModalBodyProps, "scrollMode"> {
37
35
  * Accepts every `ModalBody` prop (header, headerMode, refreshing, onRefresh,
38
36
  * stickToEnd, scrollRef, debugTag, style, contentContainerStyle, …).
39
37
  */
40
- export function ModalContent({ children, ...bodyProps }: ModalContentProps) {
38
+ export function ModalContent({
39
+ children,
40
+ scrollable = true,
41
+ ...bodyProps
42
+ }: ModalContentProps) {
41
43
  const { Modal } = getClientHost();
42
44
  return (
43
- <Modal.Content scrollable={false}>
44
- <ModalBody scrollMode="always" {...bodyProps}>
45
- {children}
46
- </ModalBody>
45
+ <Modal.Content scrollable={scrollable}>
46
+ <ModalBodyScrollOwnerContext.Provider value={scrollable ? "host" : "required"}>
47
+ <ModalBody {...bodyProps}>{children}</ModalBody>
48
+ </ModalBodyScrollOwnerContext.Provider>
47
49
  </Modal.Content>
48
50
  );
49
51
  }
@@ -20,6 +20,8 @@ export interface RegisterWorkspacePanelOptions {
20
20
  icon: string;
21
21
  Component: ComponentType<PluginWorkspacePanelProps>;
22
22
  flair?: VisualFlair;
23
+ /** Host locations in which the panel should be available. */
24
+ locations?: string[];
23
25
  }
24
26
 
25
27
  export interface RegisterAgentPanelOptions {
@@ -37,8 +39,8 @@ export interface RegisterAgentPanelOptions {
37
39
  export function registerWorkspacePanel(
38
40
  plugin: WorkspacePanelRegistrar,
39
41
  options: RegisterWorkspacePanelOptions,
40
- ): void {
41
- const { id, title, icon, Component, flair } = options;
42
+ ): () => void {
43
+ const { id, title, icon, Component, flair, locations } = options;
42
44
 
43
45
  const WrappedComponent: ComponentType<PluginWorkspacePanelProps> = (props) => (
44
46
  <PluginThemeProvider theme={props.theme} layout={props.layout} flair={flair}>
@@ -46,13 +48,19 @@ export function registerWorkspacePanel(
46
48
  </PluginThemeProvider>
47
49
  );
48
50
 
49
- plugin.addWorkspacePanel({
51
+ const registration = plugin.addWorkspacePanel({
50
52
  id,
51
53
  title,
52
54
  icon,
53
55
  context: "workspace",
56
+ locations,
54
57
  Component: WrappedComponent,
55
58
  });
59
+
60
+ return () => {
61
+ if (typeof registration === "function") registration();
62
+ else registration?.remove?.();
63
+ };
56
64
  }
57
65
 
58
66
  /**