@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 +11 -12
- package/client/multi-host-view.tsx +177 -0
- package/client/multi-host.ts +98 -0
- package/client/pill.tsx +2 -0
- package/client/surface.tsx +5 -8
- package/client/vendor/paseo-plugin-helper/components/Button.tsx +7 -1
- package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +3 -1
- package/client/vendor/paseo-plugin-helper/index.ts +1 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +3 -3
- package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +24 -22
- package/client/vendor/paseo-plugin-helper/panel.tsx +11 -3
- package/client/vendor/paseo-plugin-helper/pill.tsx +77 -28
- package/client/vendor/paseo-plugin-helper/styles/index.ts +1 -0
- package/client/vendor/paseo-plugin-helper/styles/recipes.ts +550 -0
- package/client/vendor/paseo-plugin-helper/theme/provider.tsx +3 -39
- package/client/vendor/paseo-plugin-helper/theme/tokens.ts +40 -1
- package/package.json +3 -3
- package/paseo-plugin.json +1 -1
- package/shared/multi-host.ts +341 -0
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)
|
|
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
|
-
| [](screenshots/paseo-top-general-view.jpg) | [](screenshots/paseo-top-dashboard.jpg) |
|
|
15
|
+
| [](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-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
|
|
85
|
+
Install from npm:
|
|
85
86
|
|
|
86
|
-
```
|
|
87
|
-
paseo plugin add xpufx/paseo
|
|
87
|
+
```sh
|
|
88
|
+
paseo plugin add npm:@xpufx/paseo-top
|
|
88
89
|
```
|
|
89
90
|
|
|
90
|
-
|
|
91
|
+
Or install directly from the Git repository:
|
|
91
92
|
|
|
92
|
-
```
|
|
93
|
-
|
|
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}
|
package/client/surface.tsx
CHANGED
|
@@ -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=
|
|
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=
|
|
42
|
+
accessibilityRole={accessibilityRole}
|
|
41
43
|
accessibilityLabel={accessibilityLabel || label}
|
|
42
44
|
hitSlop={Math.max(0, (touchTargetMin - 24) / 2)}
|
|
43
45
|
style={({ pressed }) => [
|
|
@@ -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 &&
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
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
|
|
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({
|
|
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={
|
|
44
|
-
<
|
|
45
|
-
{children}
|
|
46
|
-
</
|
|
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
|
/**
|