@xpufx/paseo-x-comms 0.3.0
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/LICENSE +21 -0
- package/README.md +149 -0
- package/client/attribution.ts +7 -0
- package/client/conversations.ts +229 -0
- package/client/main.tsx +885 -0
- package/client/peer-label.ts +98 -0
- package/client/peer-status.tsx +201 -0
- package/client/settings-prototype.tsx +395 -0
- package/client/tool-call.ts +115 -0
- package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
- package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
- package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
- package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
- package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
- package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
- package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
- package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
- package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
- package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
- package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
- package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
- package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
- package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
- package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
- package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
- package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
- package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
- package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
- package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
- package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
- package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
- package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
- package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
- package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
- package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
- package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
- package/client/vendor/paseo-plugin-helper/host.ts +277 -0
- package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/index.ts +28 -0
- package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
- package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
- package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
- package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
- package/client/vendor/paseo-plugin-helper/query.ts +66 -0
- package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
- package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
- package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
- package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
- package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
- package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
- package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
- package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
- package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
- package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
- package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
- package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
- package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
- package/client/via-x-comms.tsx +15 -0
- package/client/x-comms-conversation.tsx +449 -0
- package/client/x-comms-panel.tsx +6 -0
- package/client/x-comms-pill.tsx +193 -0
- package/client/x-comms-timeline.tsx +176 -0
- package/client/x-comms-tool-call.tsx +85 -0
- package/docs/mesh.md +115 -0
- package/mcp/LICENSE +202 -0
- package/mcp/README.md +287 -0
- package/mcp/mcp-config.example.json +8 -0
- package/mcp/package-lock.json +1186 -0
- package/mcp/package.json +43 -0
- package/mcp/paseo-cross-daemon-comms.example.json +5 -0
- package/mcp/paseo-x-comms.bundled.mjs +36964 -0
- package/mcp/paseo-x-comms.mjs +630 -0
- package/mcp/test/fixtures/extensions/block/10-block.mjs +4 -0
- package/mcp/test/fixtures/extensions/broken-hook/10-thrower.mjs +6 -0
- package/mcp/test/fixtures/extensions/broken-hook/20-good.mjs +4 -0
- package/mcp/test/fixtures/extensions/broken-load/10-thrower.mjs +4 -0
- package/mcp/test/fixtures/extensions/broken-load/20-good.mjs +4 -0
- package/mcp/test/fixtures/extensions/custom-tool/10-tool.mjs +12 -0
- package/mcp/test/fixtures/extensions/tool-block/10-block.mjs +8 -0
- package/mcp/test/fixtures/extensions/transform/10-transform.mjs +15 -0
- package/mcp/test/fixtures/fake-paseo.mjs +92 -0
- package/mcp/test/register-ts-hooks.mjs +6 -0
- package/mcp/test/resolve-ts-hooks.mjs +46 -0
- package/package.json +58 -0
- package/paseo-plugin.json +7 -0
- package/server/conversations-snapshot.ts +249 -0
- package/server/handlers.ts +1081 -0
- package/server/injection.ts +152 -0
- package/server/local-send.ts +153 -0
- package/server/mcp-client.ts +47 -0
- package/server/outbox.ts +233 -0
- package/server/peer-channel.ts +151 -0
- package/server/peer-status.ts +233 -0
- package/server/presence.ts +204 -0
- package/server/registry.ts +215 -0
- package/server/relay-status.ts +9 -0
- package/server/server-status.ts +138 -0
- package/server/settings.ts +81 -0
- package/server/snapshot.ts +176 -0
- package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
- package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
- package/server/vendor/paseo-plugin-helper/index.ts +18 -0
- package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
- package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
- package/server/vendor/paseo-plugin-helper/mcp/client.ts +349 -0
- package/server/vendor/paseo-plugin-helper/mcp/http-client.ts +399 -0
- package/server/vendor/paseo-plugin-helper/mcp/index.ts +5 -0
- package/server/vendor/paseo-plugin-helper/mcp/process-killer.ts +65 -0
- package/server/vendor/paseo-plugin-helper/mcp/ring-buffer.ts +29 -0
- package/server/vendor/paseo-plugin-helper/mcp/types.ts +41 -0
- package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
- package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
- package/server/vendor/paseo-plugin-helper/network.ts +91 -0
- package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
- package/server/vendor/paseo-plugin-helper/process.ts +186 -0
- package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
- package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
- package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
- package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
- package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
- package/server/vendor/paseo-plugin-helper/system.ts +128 -0
- package/server/vendor/paseo-plugin-helper/task.ts +116 -0
- package/server/vendor/paseo-plugin-helper/version.ts +153 -0
- package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
- package/shared/conversations-snapshot.ts +39 -0
- package/shared/envelope.ts +92 -0
- package/shared/outbox.ts +21 -0
- package/shared/registry.ts +366 -0
- package/shared/vendor/paseo-plugin-helper/README.md +11 -0
- package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
- package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
- package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
- package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
- package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
- package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
- package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
- package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
- package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
- package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
- package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
- package/shared/version.ts +2 -0
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useEffect,
|
|
3
|
+
useLayoutEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useState,
|
|
6
|
+
useSyncExternalStore,
|
|
7
|
+
type ComponentType,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { StyleSheet, Text, useWindowDimensions, View } from "react-native";
|
|
11
|
+
import {
|
|
12
|
+
getClientHost,
|
|
13
|
+
type ComposerPillRegistrar,
|
|
14
|
+
type ComposerPillRegistration,
|
|
15
|
+
type ComposerPillRegistrationHandle,
|
|
16
|
+
type HostLayout,
|
|
17
|
+
type HostPillProps,
|
|
18
|
+
type HostTheme,
|
|
19
|
+
type PluginCleanup,
|
|
20
|
+
} from "./host";
|
|
21
|
+
import { PluginThemeProvider } from "./theme/provider";
|
|
22
|
+
import { useResponsive } from "./theme/useResponsive";
|
|
23
|
+
import type { VisualFlair } from "./theme/flair";
|
|
24
|
+
import { ModalBodyScrollOwnerContext } from "./layout/ModalBody";
|
|
25
|
+
import { reportSuppressed } from "../../../shared/vendor/paseo-plugin-helper/suppressed";
|
|
26
|
+
|
|
27
|
+
export interface RenderPillProps<TPayload = any> extends HostPillProps {
|
|
28
|
+
isOpen: boolean;
|
|
29
|
+
open: (payload?: TPayload) => void;
|
|
30
|
+
close: () => void;
|
|
31
|
+
toggle: (payload?: TPayload) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RenderModalProps<TPayload = any> extends HostPillProps {
|
|
35
|
+
close: () => void;
|
|
36
|
+
payload?: TPayload;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PillLiveContext {
|
|
40
|
+
agentId: string;
|
|
41
|
+
workspaceId: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PillLivePayload {
|
|
45
|
+
label?: string;
|
|
46
|
+
icon?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type PillLabelResolver = (
|
|
50
|
+
context: PillLiveContext,
|
|
51
|
+
) =>
|
|
52
|
+
| string
|
|
53
|
+
| PillLivePayload
|
|
54
|
+
| undefined
|
|
55
|
+
| Promise<string | PillLivePayload | undefined>;
|
|
56
|
+
|
|
57
|
+
export type PillIconResolver = (
|
|
58
|
+
context: PillLiveContext,
|
|
59
|
+
) => string | undefined | Promise<string | undefined>;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Single precedence rule for pill modal scroll ownership (#219), shared by
|
|
63
|
+
* the centered and legacy modal wrappers so they cannot disagree.
|
|
64
|
+
* `true` delegates the scroller to the host `<Modal.Content>`; `false`
|
|
65
|
+
* (default) keeps the legacy bounded dialog for `ModalBody`-based content.
|
|
66
|
+
* Either way the wrapper renders exactly one `<Modal.Content>`.
|
|
67
|
+
*/
|
|
68
|
+
export function resolvePillModalScrollable(hostScroll?: boolean): boolean {
|
|
69
|
+
return hostScroll === true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let probeSequence = 0;
|
|
73
|
+
|
|
74
|
+
const NOOP_SUBSCRIBE = (): (() => void) => () => {};
|
|
75
|
+
|
|
76
|
+
export interface RegisterComposerPillOptions<TPayload = any> {
|
|
77
|
+
/**
|
|
78
|
+
* Unique ID for the pill (e.g. "paseo-top", "mcp-monitor").
|
|
79
|
+
*/
|
|
80
|
+
id: string;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Title shown in the composer trackbar (keep concise, e.g. "top", "CPU 12%").
|
|
84
|
+
*/
|
|
85
|
+
title: string;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Optional compact title shown in the composer trackbar when screen or track is narrow/mobile
|
|
89
|
+
* (when `layout.compact` is true). Defaults to `title`.\
|
|
90
|
+
*/
|
|
91
|
+
compactTitle?: string;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Optional custom title shown in the modal header (defaults to `title`).
|
|
95
|
+
* Useful when the modal needs a full descriptive title (e.g. "Host System Resources").
|
|
96
|
+
*/
|
|
97
|
+
modalTitle?: string;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Lucide icon name for the pill (e.g. "Cpu", "Server", "MessageSquare").
|
|
101
|
+
*/
|
|
102
|
+
icon?: string;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Optional compact Lucide icon name shown when in compact mode. Defaults to `icon`.
|
|
106
|
+
*/
|
|
107
|
+
compactIcon?: string;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Optional custom icon for the modal header. Can be a Lucide icon name string or a JSX element.
|
|
111
|
+
* If omitted, falls back to `icon`.
|
|
112
|
+
*/
|
|
113
|
+
modalIcon?: string | ReactNode;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Optional visual flair preset or overrides for the plugin's theme.
|
|
117
|
+
*/
|
|
118
|
+
flair?: Partial<VisualFlair>;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Optional custom badge text shown inside the default pill (e.g. "LIVE", "3").
|
|
122
|
+
*/
|
|
123
|
+
badgeText?: string;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Optional compact badge text shown inside the default pill in compact mode. Defaults to `badgeText`.
|
|
127
|
+
*/
|
|
128
|
+
compactBadgeText?: string;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Optional callback to resolve default payload when the outer host pill is clicked.
|
|
132
|
+
* Receives agentId and workspaceId.
|
|
133
|
+
*/
|
|
134
|
+
resolveDefaultPayload?: (context: { agentId: string; workspaceId: string }) => TPayload | undefined;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Custom pill body renderer if you want to replace the default pill layout.
|
|
138
|
+
* Receives `isOpen`, `open`, `close`, and `toggle` along with standard pill props.\
|
|
139
|
+
*/
|
|
140
|
+
renderPill?: (props: RenderPillProps<TPayload>) => ReactNode;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Resolves the live pill label (and optionally icon) on button-shaped hosts (Paseo 0.8+), where the
|
|
144
|
+
* pill body is host-rendered from a static `label` and `icon` string and `renderPill`
|
|
145
|
+
* never mounts. Called once at registration and then every
|
|
146
|
+
* `refreshIntervalMs`. Keep it cheap and synchronous when possible; async
|
|
147
|
+
* resolvers are awaited. Returning `undefined` leaves the current label/icon.
|
|
148
|
+
* Can return a plain string (label) or an object `{ label?: string; icon?: string }`.
|
|
149
|
+
* Cycle modes can advance rotation state on each call.
|
|
150
|
+
*/
|
|
151
|
+
resolveLabel?: PillLabelResolver;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Optional standalone resolver for the button icon on button-shaped hosts (Paseo 0.8+).
|
|
155
|
+
* Evaluated alongside `resolveLabel` on each tick.
|
|
156
|
+
*/
|
|
157
|
+
resolveIcon?: PillIconResolver;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Poll interval for `resolveLabel` on button-shaped hosts. Defaults to 5000ms
|
|
161
|
+
* when `resolveLabel` or `resolveIcon` is set. Set to 0 to resolve once at registration.
|
|
162
|
+
* Ignored on legacy hosts (their `renderPill` re-renders via React state).
|
|
163
|
+
*/
|
|
164
|
+
refreshIntervalMs?: number;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Fixed width, in pixels, for the anchored popover on non-compact hosts.
|
|
168
|
+
*
|
|
169
|
+
* Without it the popover is sized from its content, so any content that
|
|
170
|
+
* reflows (a measured table, a responsive group, a gauge that settles) can
|
|
171
|
+
* resize the surface under the pointer. Pinning the width makes the frame the
|
|
172
|
+
* authority and lets the content overflow into its own scroll/clip instead.
|
|
173
|
+
*
|
|
174
|
+
* Ignored on compact hosts, where the same content renders in a full-bleed
|
|
175
|
+
* bottom sheet. Clamped to the window width so a fixed width cannot overflow
|
|
176
|
+
* a narrow viewport.
|
|
177
|
+
*/
|
|
178
|
+
popoverWidth?: number;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Renders the content inside the controlled modal.
|
|
182
|
+
* Automatically wrapped with PluginThemeProvider and supplied with a `close()` helper and optional payload.
|
|
183
|
+
* On button-shaped hosts (Paseo 0.8+) the modal is replaced by an anchored
|
|
184
|
+
* popover rendering this same content at the host surface width (expect a
|
|
185
|
+
* narrow column, not a wide modal); keep content vertically stacked and
|
|
186
|
+
* reflowing. `open`/`toggle` from `renderPill` cannot drive host-owned
|
|
187
|
+
* popovers, so live pill text comes from `resolveLabel` instead.
|
|
188
|
+
*
|
|
189
|
+
* The wrapper renders exactly one host `<Modal.Content>` around this output
|
|
190
|
+
* on the modal paths (legacy + centered). Never render another
|
|
191
|
+
* `<Modal.Content>` here — use `HostModalSection` from
|
|
192
|
+
* `paseo-plugin-helper/ui` for fluid content. (`HostModalContent` is only
|
|
193
|
+
* for plugins that open their OWN host `<Modal>`.)
|
|
194
|
+
*/
|
|
195
|
+
renderModal?: (props: RenderModalProps<TPayload>) => ReactNode;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Host-owned scroll for the pill modal paths (#219).
|
|
199
|
+
* - `false` (default, legacy): the wrapper renders
|
|
200
|
+
* `<Modal.Content scrollable={false}>` (bounded dialog) and
|
|
201
|
+
* `ModalBody`-based content owns the one scroller.
|
|
202
|
+
* - `true`: the wrapper renders `<Modal.Content scrollable={true}>` so the
|
|
203
|
+
* host scrolls, and `renderModal` must provide fluid content with NO
|
|
204
|
+
* nested `<Modal.Content>` or scroller (`HostModalSection`).
|
|
205
|
+
* Exactly one `<Modal.Content>` is rendered in both modes. The 0.8 popover
|
|
206
|
+
* path is unaffected (plain host-owned container either way).
|
|
207
|
+
*/
|
|
208
|
+
hostScroll?: boolean;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Makes the pill an action button instead of a tethered popover: pressing it
|
|
212
|
+
* calls this and the host never mounts a popover. Use it to open a plugin
|
|
213
|
+
* surface (`openSurface(id)`), which the host renders outside the composer —
|
|
214
|
+
* an agent-stream re-render of the composer then cannot remount it. Ignored
|
|
215
|
+
* when unset, in which case `renderModal` renders the popover.
|
|
216
|
+
*/
|
|
217
|
+
onPress?: () => void | Promise<void>;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* How an open pill presents.
|
|
221
|
+
* - `"popover"` (default): the host anchors `renderModal` to the pill. The
|
|
222
|
+
* host may remount that subtree on every composer re-render, which can tear
|
|
223
|
+
* an open popover down.
|
|
224
|
+
* - `"centered"`: the host `Modal` is rendered from the pill's always-mounted
|
|
225
|
+
* icon and toggled by the pill press, so the surface is not a child of the
|
|
226
|
+
* composer popover and a composer re-render does not unmount it.
|
|
227
|
+
*/
|
|
228
|
+
presentation?: "popover" | "centered";
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Called when a pill cannot be registered on the current host (for example
|
|
232
|
+
* a host API mismatch). Reporting instead of throwing keeps the rest of the
|
|
233
|
+
* plugin client alive; render the message in your own panel to make it visible.
|
|
234
|
+
*/
|
|
235
|
+
onError?: (info: { agentId: string; workspaceId: string; error: Error }) => void;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Registers an agent-scoped composer pill and modal lifecycle.
|
|
240
|
+
* Manages agent subscription events, unmount cleanup, and pill-to-modal activation.
|
|
241
|
+
*
|
|
242
|
+
* Works against both host generations: legacy `{Component, onPress}` pills
|
|
243
|
+
* (Paseo 0.7 and beta apps) and `button`-descriptor pills (Paseo 0.8+), detected
|
|
244
|
+
* once per call with a throwaway probe registration that is removed immediately.
|
|
245
|
+
*/
|
|
246
|
+
export function registerComposerPill<TPayload = any>(
|
|
247
|
+
client: ComposerPillRegistrar,
|
|
248
|
+
options: RegisterComposerPillOptions<TPayload>,
|
|
249
|
+
): PluginCleanup {
|
|
250
|
+
const { Icon, Modal } = getClientHost();
|
|
251
|
+
const openers = new Map<string, (payload?: TPayload) => void>();
|
|
252
|
+
const pills = new Map<
|
|
253
|
+
string,
|
|
254
|
+
{
|
|
255
|
+
dispose: () => void;
|
|
256
|
+
workspaceId: string;
|
|
257
|
+
registration?: ComposerPillRegistrationHandle;
|
|
258
|
+
}
|
|
259
|
+
>();
|
|
260
|
+
const pushedDescriptors = new WeakMap<object, { label?: string; icon?: string }>();
|
|
261
|
+
// A button-shaped pill can only be resolved while the host actually renders
|
|
262
|
+
// it. The host mounts the pill's custom icon component for every visible
|
|
263
|
+
// composer pill, and that mount is the plugin's only visibility signal: its
|
|
264
|
+
// mount/unmount brackets the pill's on-screen lifetime. Polling every
|
|
265
|
+
// registered agent regardless of that signal ran one ticker per agent in the
|
|
266
|
+
// install, which stampedes the daemon on a host with many agents.
|
|
267
|
+
const visiblePillMounts = new Map<string, number>();
|
|
268
|
+
// Agent ids whose anchored popover is currently mounted. While a popover is
|
|
269
|
+
// open the host re-renders the whole composer on every agent-stream message;
|
|
270
|
+
// rewriting the button entry at the same time makes the host remount the open
|
|
271
|
+
// popover. `mcp-tools` never updates its entry and its popover is stable, so
|
|
272
|
+
// keep the entry static while the popover is up — the label and icon are
|
|
273
|
+
// hidden behind it anyway. Updates resume from the next tick after it closes.
|
|
274
|
+
const openPopoverAgents = new Set<string>();
|
|
275
|
+
const iconValueByAgent = new Map<string, string>();
|
|
276
|
+
const iconListenersByAgent = new Map<string, Set<() => void>>();
|
|
277
|
+
// Open state for `presentation: "centered"`. It lives outside React so the
|
|
278
|
+
// centered Modal, which is rendered from the pill icon, survives any remount
|
|
279
|
+
// of the composer subtree the host might do.
|
|
280
|
+
const centeredOpenAgents = new Set<string>();
|
|
281
|
+
const centeredListenersByAgent = new Map<string, Set<() => void>>();
|
|
282
|
+
let sharedLabelTimer: ReturnType<typeof setInterval> | null = null;
|
|
283
|
+
let detectedShape: "button" | "legacy" | null = null;
|
|
284
|
+
let disposed = false;
|
|
285
|
+
|
|
286
|
+
function defaultPillIcon(): string {
|
|
287
|
+
if (typeof options.icon === "string") return options.icon;
|
|
288
|
+
if (typeof options.modalIcon === "string") return options.modalIcon;
|
|
289
|
+
return "Activity";
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function readPillIcon(agentId: string): string {
|
|
293
|
+
return iconValueByAgent.get(agentId) ?? defaultPillIcon();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function subscribePillIcon(agentId: string): (listener: () => void) => () => void {
|
|
297
|
+
return (listener: () => void) => {
|
|
298
|
+
let listeners = iconListenersByAgent.get(agentId);
|
|
299
|
+
if (!listeners) {
|
|
300
|
+
listeners = new Set();
|
|
301
|
+
iconListenersByAgent.set(agentId, listeners);
|
|
302
|
+
}
|
|
303
|
+
listeners.add(listener);
|
|
304
|
+
return () => {
|
|
305
|
+
listeners?.delete(listener);
|
|
306
|
+
if (listeners && listeners.size === 0) iconListenersByAgent.delete(agentId);
|
|
307
|
+
};
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function setPillIcon(agentId: string, icon: string): void {
|
|
312
|
+
if (iconValueByAgent.get(agentId) === icon) return;
|
|
313
|
+
iconValueByAgent.set(agentId, icon);
|
|
314
|
+
for (const listener of iconListenersByAgent.get(agentId) ?? []) listener();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function subscribeCenteredOpen(agentId: string): (listener: () => void) => () => void {
|
|
318
|
+
return (listener: () => void) => {
|
|
319
|
+
let listeners = centeredListenersByAgent.get(agentId);
|
|
320
|
+
if (!listeners) {
|
|
321
|
+
listeners = new Set();
|
|
322
|
+
centeredListenersByAgent.set(agentId, listeners);
|
|
323
|
+
}
|
|
324
|
+
listeners.add(listener);
|
|
325
|
+
return () => {
|
|
326
|
+
listeners?.delete(listener);
|
|
327
|
+
if (listeners && listeners.size === 0) centeredListenersByAgent.delete(agentId);
|
|
328
|
+
};
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function setCenteredOpen(agentId: string, open: boolean): void {
|
|
333
|
+
if (centeredOpenAgents.has(agentId) === open) return;
|
|
334
|
+
if (open) centeredOpenAgents.add(agentId);
|
|
335
|
+
else centeredOpenAgents.delete(agentId);
|
|
336
|
+
for (const listener of centeredListenersByAgent.get(agentId) ?? []) listener();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function visibleAgentIds(): string[] {
|
|
340
|
+
const ids: string[] = [];
|
|
341
|
+
for (const [agentId, count] of visiblePillMounts) {
|
|
342
|
+
if (count > 0) ids.push(agentId);
|
|
343
|
+
}
|
|
344
|
+
return ids;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function syncSharedLabelTimer(): void {
|
|
348
|
+
const intervalMs = options.refreshIntervalMs ?? 5000;
|
|
349
|
+
const hasPoller = Boolean(options.resolveLabel || options.resolveIcon);
|
|
350
|
+
const shouldRun =
|
|
351
|
+
hasPoller && intervalMs > 0 && visibleAgentIds().some((agentId) => pills.has(agentId));
|
|
352
|
+
if (!shouldRun) {
|
|
353
|
+
if (sharedLabelTimer) {
|
|
354
|
+
clearInterval(sharedLabelTimer);
|
|
355
|
+
sharedLabelTimer = null;
|
|
356
|
+
}
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (sharedLabelTimer) return;
|
|
360
|
+
sharedLabelTimer = setInterval(() => {
|
|
361
|
+
for (const agentId of visibleAgentIds()) {
|
|
362
|
+
const entry = pills.get(agentId);
|
|
363
|
+
if (entry?.registration) resolveAndPushLabel(agentId, entry.workspaceId, entry.registration);
|
|
364
|
+
}
|
|
365
|
+
}, intervalMs);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function markPillVisible(agentId: string): void {
|
|
369
|
+
visiblePillMounts.set(agentId, (visiblePillMounts.get(agentId) ?? 0) + 1);
|
|
370
|
+
const entry = pills.get(agentId);
|
|
371
|
+
if (entry?.registration) resolveAndPushLabel(agentId, entry.workspaceId, entry.registration);
|
|
372
|
+
syncSharedLabelTimer();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function markPillHidden(agentId: string): void {
|
|
376
|
+
const next = (visiblePillMounts.get(agentId) ?? 1) - 1;
|
|
377
|
+
if (next > 0) visiblePillMounts.set(agentId, next);
|
|
378
|
+
else visiblePillMounts.delete(agentId);
|
|
379
|
+
syncSharedLabelTimer();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The host-rendered trigger glyph doubles as the pill's visibility probe.
|
|
384
|
+
* Swapping the live icon is routed through `setPillIcon` rather than
|
|
385
|
+
* `registration.update({ icon })`, so the probe component never gets
|
|
386
|
+
* replaced by a plain string and the mount signal survives every icon swap.
|
|
387
|
+
*/
|
|
388
|
+
function makePillIcon(agentId: string): ComponentType<{
|
|
389
|
+
size?: number;
|
|
390
|
+
color?: string;
|
|
391
|
+
theme?: HostTheme;
|
|
392
|
+
layout?: HostLayout;
|
|
393
|
+
host?: { id: string; label: string };
|
|
394
|
+
workspaceId?: string;
|
|
395
|
+
}> {
|
|
396
|
+
return function PillVisibilityIcon(props: {
|
|
397
|
+
size?: number;
|
|
398
|
+
color?: string;
|
|
399
|
+
theme?: HostTheme;
|
|
400
|
+
layout?: HostLayout;
|
|
401
|
+
host?: { id: string; label: string };
|
|
402
|
+
workspaceId?: string;
|
|
403
|
+
}) {
|
|
404
|
+
const subscribe = useMemo(() => subscribePillIcon(agentId), [agentId]);
|
|
405
|
+
const name = useSyncExternalStore(
|
|
406
|
+
subscribe,
|
|
407
|
+
() => readPillIcon(agentId),
|
|
408
|
+
() => readPillIcon(agentId),
|
|
409
|
+
);
|
|
410
|
+
const centered = options.presentation === "centered";
|
|
411
|
+
const subscribeCentered = useMemo(
|
|
412
|
+
() => (centered ? subscribeCenteredOpen(agentId) : NOOP_SUBSCRIBE),
|
|
413
|
+
[centered, agentId],
|
|
414
|
+
);
|
|
415
|
+
const centeredOpen = useSyncExternalStore(
|
|
416
|
+
subscribeCentered,
|
|
417
|
+
() => (centered ? centeredOpenAgents.has(agentId) : false),
|
|
418
|
+
() => (centered ? centeredOpenAgents.has(agentId) : false),
|
|
419
|
+
);
|
|
420
|
+
useEffect(() => {
|
|
421
|
+
markPillVisible(agentId);
|
|
422
|
+
return () => markPillHidden(agentId);
|
|
423
|
+
// agentId is fixed per pill; the icon component identity never changes.
|
|
424
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
425
|
+
}, [agentId]);
|
|
426
|
+
|
|
427
|
+
const icon = <Icon name={name} size={props.size ?? 14} color={props.color ?? ""} />;
|
|
428
|
+
const { theme, layout } = props;
|
|
429
|
+
if (!centered || !options.renderModal || !theme || !layout) return icon;
|
|
430
|
+
|
|
431
|
+
const modalIconElement = React.isValidElement(options.modalIcon) ? (
|
|
432
|
+
options.modalIcon
|
|
433
|
+
) : (
|
|
434
|
+
<Icon
|
|
435
|
+
name={
|
|
436
|
+
typeof options.modalIcon === "string" ? options.modalIcon : (options.icon ?? "Activity")
|
|
437
|
+
}
|
|
438
|
+
size={16}
|
|
439
|
+
color={theme.colors.foreground}
|
|
440
|
+
/>
|
|
441
|
+
);
|
|
442
|
+
return (
|
|
443
|
+
<>
|
|
444
|
+
{icon}
|
|
445
|
+
<Modal
|
|
446
|
+
title={options.modalTitle ?? options.title}
|
|
447
|
+
icon={modalIconElement}
|
|
448
|
+
open={centeredOpen}
|
|
449
|
+
onOpenChange={(nextOpen: boolean) => setCenteredOpen(agentId, nextOpen)}
|
|
450
|
+
>
|
|
451
|
+
<Modal.Content scrollable={resolvePillModalScrollable(options.hostScroll)}>
|
|
452
|
+
{centeredOpen ? (
|
|
453
|
+
<PluginThemeProvider theme={theme} layout={layout} flair={options.flair}>
|
|
454
|
+
{options.renderModal({
|
|
455
|
+
agentId,
|
|
456
|
+
workspaceId: props.workspaceId ?? "",
|
|
457
|
+
theme,
|
|
458
|
+
layout,
|
|
459
|
+
host: props.host ?? { id: "", label: "" },
|
|
460
|
+
close: () => setCenteredOpen(agentId, false),
|
|
461
|
+
})}
|
|
462
|
+
</PluginThemeProvider>
|
|
463
|
+
) : null}
|
|
464
|
+
</Modal.Content>
|
|
465
|
+
</Modal>
|
|
466
|
+
</>
|
|
467
|
+
);
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// 0.8 popover scroll ownership: Paseo's MenuSurface/FloatingScrollView or
|
|
472
|
+
// BottomSheetScrollView already owns the viewport. Keep this wrapper plain,
|
|
473
|
+
// unconstrained, and mark the subtree so ModalBody does not add a second
|
|
474
|
+
// ScrollView. Fixed heights or overflow clipping here cut off mobile content.
|
|
475
|
+
function PillPopoverContent(props: {
|
|
476
|
+
agentId: string;
|
|
477
|
+
workspaceId: string;
|
|
478
|
+
theme: HostTheme;
|
|
479
|
+
layout: HostLayout;
|
|
480
|
+
host?: { id: string; label: string };
|
|
481
|
+
close: () => void;
|
|
482
|
+
}) {
|
|
483
|
+
const { width: windowWidth } = useWindowDimensions();
|
|
484
|
+
// The anchored popover is otherwise sized from its content, so reflowing
|
|
485
|
+
// content can move the frame under the pointer. Pin it to an explicit width
|
|
486
|
+
// (clamped to the viewport) and let children overflow into their own
|
|
487
|
+
// scroll/clip. Compact hosts present a full-bleed sheet, so this is skipped.
|
|
488
|
+
const frameWidth =
|
|
489
|
+
!props.layout.compact && options.popoverWidth
|
|
490
|
+
? Math.max(0, Math.min(options.popoverWidth, windowWidth - 24))
|
|
491
|
+
: undefined;
|
|
492
|
+
// Layout effect, not effect: the flag must be set in the same commit the
|
|
493
|
+
// popover mounts, before any label timer can interleave. An effect runs
|
|
494
|
+
// after paint, leaving a window where one tick can still publish and
|
|
495
|
+
// remount the popover.
|
|
496
|
+
useLayoutEffect(() => {
|
|
497
|
+
openPopoverAgents.add(props.agentId);
|
|
498
|
+
return () => {
|
|
499
|
+
openPopoverAgents.delete(props.agentId);
|
|
500
|
+
};
|
|
501
|
+
}, [props.agentId]);
|
|
502
|
+
const pillProps: HostPillProps = {
|
|
503
|
+
agentId: props.agentId,
|
|
504
|
+
workspaceId: props.workspaceId,
|
|
505
|
+
theme: props.theme,
|
|
506
|
+
layout: props.layout,
|
|
507
|
+
host: props.host ?? { id: "", label: "" },
|
|
508
|
+
};
|
|
509
|
+
return (
|
|
510
|
+
<PluginThemeProvider theme={props.theme} layout={props.layout} flair={options.flair}>
|
|
511
|
+
<View style={[styles.popoverContainer, frameWidth ? { width: frameWidth } : null]}>
|
|
512
|
+
<ModalBodyScrollOwnerContext.Provider value="host">
|
|
513
|
+
{options.renderModal?.({ ...pillProps, close: props.close })}
|
|
514
|
+
</ModalBodyScrollOwnerContext.Provider>
|
|
515
|
+
</View>
|
|
516
|
+
</PluginThemeProvider>
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function PillHost(props: HostPillProps) {
|
|
521
|
+
const [open, setOpen] = useState(false);
|
|
522
|
+
const [payload, setPayload] = useState<TPayload | undefined>(undefined);
|
|
523
|
+
|
|
524
|
+
useEffect(() => {
|
|
525
|
+
openers.set(props.agentId, (incomingPayload?: TPayload) => {
|
|
526
|
+
setPayload(incomingPayload);
|
|
527
|
+
setOpen(true);
|
|
528
|
+
});
|
|
529
|
+
return () => {
|
|
530
|
+
openers.delete(props.agentId);
|
|
531
|
+
};
|
|
532
|
+
}, [props.agentId]);
|
|
533
|
+
|
|
534
|
+
const effectiveModalTitle = options.modalTitle ?? options.title;
|
|
535
|
+
|
|
536
|
+
const modalIconElement = useMemo(() => {
|
|
537
|
+
if (React.isValidElement(options.modalIcon)) {
|
|
538
|
+
return options.modalIcon;
|
|
539
|
+
}
|
|
540
|
+
const iconName =
|
|
541
|
+
typeof options.modalIcon === "string" ? options.modalIcon : options.icon;
|
|
542
|
+
if (iconName) {
|
|
543
|
+
return <Icon name={iconName} size={16} color={props.theme.colors.foreground} />;
|
|
544
|
+
}
|
|
545
|
+
return undefined;
|
|
546
|
+
}, [options.modalIcon, options.icon, props.theme.colors.foreground]);
|
|
547
|
+
|
|
548
|
+
const renderPillProps: RenderPillProps<TPayload> = {
|
|
549
|
+
...props,
|
|
550
|
+
isOpen: open,
|
|
551
|
+
open: (customPayload?: TPayload) => {
|
|
552
|
+
setPayload(customPayload);
|
|
553
|
+
setOpen(true);
|
|
554
|
+
},
|
|
555
|
+
close: () => setOpen(false),
|
|
556
|
+
toggle: (customPayload?: TPayload) => {
|
|
557
|
+
setPayload(customPayload);
|
|
558
|
+
setOpen((prev) => !prev);
|
|
559
|
+
},
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
return (
|
|
563
|
+
<PluginThemeProvider theme={props.theme} layout={props.layout} flair={options.flair}>
|
|
564
|
+
{options.renderPill ? (
|
|
565
|
+
options.renderPill(renderPillProps)
|
|
566
|
+
) : (
|
|
567
|
+
<DefaultPillBody
|
|
568
|
+
title={options.title}
|
|
569
|
+
compactTitle={options.compactTitle}
|
|
570
|
+
icon={options.icon}
|
|
571
|
+
compactIcon={options.compactIcon}
|
|
572
|
+
badgeText={options.badgeText}
|
|
573
|
+
compactBadgeText={options.compactBadgeText}
|
|
574
|
+
theme={props.theme}
|
|
575
|
+
/>
|
|
576
|
+
)}
|
|
577
|
+
|
|
578
|
+
<Modal
|
|
579
|
+
title={effectiveModalTitle}
|
|
580
|
+
icon={modalIconElement}
|
|
581
|
+
open={open}
|
|
582
|
+
onOpenChange={(nextOpen) => {
|
|
583
|
+
setOpen(nextOpen);
|
|
584
|
+
if (!nextOpen) {
|
|
585
|
+
setPayload(undefined);
|
|
586
|
+
}
|
|
587
|
+
}}
|
|
588
|
+
>
|
|
589
|
+
<Modal.Content scrollable={resolvePillModalScrollable(options.hostScroll)}>
|
|
590
|
+
{open ? (
|
|
591
|
+
<PluginThemeProvider theme={props.theme} layout={props.layout} flair={options.flair}>
|
|
592
|
+
{options.renderModal?.({
|
|
593
|
+
...props,
|
|
594
|
+
close: () => setOpen(false),
|
|
595
|
+
payload,
|
|
596
|
+
})}
|
|
597
|
+
</PluginThemeProvider>
|
|
598
|
+
) : null}
|
|
599
|
+
</Modal.Content>
|
|
600
|
+
|
|
601
|
+
</Modal>
|
|
602
|
+
</PluginThemeProvider>
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function toCleanup(registration: ComposerPillRegistration): PluginCleanup {
|
|
607
|
+
if (typeof registration === "function") return registration;
|
|
608
|
+
return () => registration.remove();
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function reportError(agentId: string, workspaceId: string, error: unknown): void {
|
|
612
|
+
pills.set(agentId, {
|
|
613
|
+
dispose: () => {},
|
|
614
|
+
workspaceId,
|
|
615
|
+
});
|
|
616
|
+
// Surface even when the plugin wired no onError: a swallowed registration
|
|
617
|
+
// failure leaves the pill missing with no trace anywhere.
|
|
618
|
+
reportSuppressed(console, `registerComposerPill registration failed (agent ${agentId})`, error, "warn");
|
|
619
|
+
options.onError?.({
|
|
620
|
+
agentId,
|
|
621
|
+
workspaceId,
|
|
622
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function resolveAndPushLabel(
|
|
627
|
+
agentId: string,
|
|
628
|
+
workspaceId: string,
|
|
629
|
+
registration: ComposerPillRegistration,
|
|
630
|
+
): void {
|
|
631
|
+
if (typeof registration === "function") return;
|
|
632
|
+
if (!options.resolveLabel && !options.resolveIcon) return;
|
|
633
|
+
const ctx = { agentId, workspaceId };
|
|
634
|
+
Promise.all([
|
|
635
|
+
options.resolveLabel ? Promise.resolve(options.resolveLabel(ctx)) : Promise.resolve(undefined),
|
|
636
|
+
options.resolveIcon ? Promise.resolve(options.resolveIcon(ctx)) : Promise.resolve(undefined),
|
|
637
|
+
])
|
|
638
|
+
.then(([labelResult, iconResult]) => {
|
|
639
|
+
if (!pills.has(agentId)) return;
|
|
640
|
+
if (openPopoverAgents.has(agentId)) return;
|
|
641
|
+
const patch: { label?: string; icon?: string } = {};
|
|
642
|
+
if (typeof labelResult === "string") {
|
|
643
|
+
patch.label = labelResult;
|
|
644
|
+
} else if (labelResult && typeof labelResult === "object") {
|
|
645
|
+
if (labelResult.label !== undefined) patch.label = labelResult.label;
|
|
646
|
+
if (labelResult.icon !== undefined) patch.icon = labelResult.icon;
|
|
647
|
+
}
|
|
648
|
+
if (iconResult !== undefined) {
|
|
649
|
+
patch.icon = iconResult;
|
|
650
|
+
}
|
|
651
|
+
const previous = pushedDescriptors.get(registration as object) ?? {};
|
|
652
|
+
const labelChanged = patch.label !== undefined && patch.label !== previous.label;
|
|
653
|
+
const iconChanged = patch.icon !== undefined && patch.icon !== previous.icon;
|
|
654
|
+
// Icon updates go through the mount probe, never `button.icon`: replacing
|
|
655
|
+
// the probe with a string would unmount it and lose the visibility signal.
|
|
656
|
+
if (iconChanged) setPillIcon(agentId, patch.icon as string);
|
|
657
|
+
if (labelChanged) registration.update({ label: patch.label });
|
|
658
|
+
if (labelChanged || iconChanged) {
|
|
659
|
+
pushedDescriptors.set(registration as object, {
|
|
660
|
+
label: patch.label ?? previous.label,
|
|
661
|
+
icon: patch.icon ?? previous.icon,
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
})
|
|
665
|
+
.catch((error) => {
|
|
666
|
+
// resolveLabel/resolveIcon usually perform an RPC; a rejection here is
|
|
667
|
+
// the pill's live data failing, so it must not vanish when onError is unset.
|
|
668
|
+
reportSuppressed(
|
|
669
|
+
console,
|
|
670
|
+
`registerComposerPill resolveLabel/resolveIcon failed (agent ${agentId})`,
|
|
671
|
+
error,
|
|
672
|
+
);
|
|
673
|
+
options.onError?.({
|
|
674
|
+
agentId,
|
|
675
|
+
workspaceId,
|
|
676
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function detectShape(agentId: string, workspaceId: string): "button" | "legacy" {
|
|
682
|
+
probeSequence += 1;
|
|
683
|
+
const probeId = `php-probe-${probeSequence}`;
|
|
684
|
+
try {
|
|
685
|
+
const registration = client.addComposerPill({
|
|
686
|
+
id: probeId,
|
|
687
|
+
workspaceId,
|
|
688
|
+
agentId,
|
|
689
|
+
button: {
|
|
690
|
+
title: "probe",
|
|
691
|
+
icon: "Activity",
|
|
692
|
+
behavior: {
|
|
693
|
+
kind: "action",
|
|
694
|
+
onPress() {},
|
|
695
|
+
},
|
|
696
|
+
},
|
|
697
|
+
});
|
|
698
|
+
toCleanup(registration)();
|
|
699
|
+
return "button";
|
|
700
|
+
} catch {
|
|
701
|
+
return "legacy";
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function addPill(agentId: string, workspaceId: string) {
|
|
706
|
+
if (disposed || pills.has(agentId)) return;
|
|
707
|
+
try {
|
|
708
|
+
if (!detectedShape) {
|
|
709
|
+
detectedShape = detectShape(agentId, workspaceId);
|
|
710
|
+
}
|
|
711
|
+
if (detectedShape === "button") {
|
|
712
|
+
const registration = client.addComposerPill({
|
|
713
|
+
id: options.id,
|
|
714
|
+
workspaceId,
|
|
715
|
+
agentId,
|
|
716
|
+
button: {
|
|
717
|
+
title: options.title,
|
|
718
|
+
// The probe is the visibility signal and the live-icon renderer.
|
|
719
|
+
icon: makePillIcon(agentId),
|
|
720
|
+
label: options.title,
|
|
721
|
+
behavior: options.onPress
|
|
722
|
+
? { kind: "action", onPress: () => void options.onPress?.() }
|
|
723
|
+
: options.presentation === "centered"
|
|
724
|
+
? {
|
|
725
|
+
kind: "action",
|
|
726
|
+
onPress: () => setCenteredOpen(agentId, !centeredOpenAgents.has(agentId)),
|
|
727
|
+
}
|
|
728
|
+
: {
|
|
729
|
+
kind: "popover",
|
|
730
|
+
Content: PillPopoverContent as ComponentType<any>,
|
|
731
|
+
},
|
|
732
|
+
},
|
|
733
|
+
});
|
|
734
|
+
const handle = typeof registration === "function" ? undefined : registration;
|
|
735
|
+
pills.set(agentId, { dispose: toCleanup(registration), workspaceId, registration: handle });
|
|
736
|
+
// Resolve once so the label is correct even before the probe mounts;
|
|
737
|
+
// ongoing polling is owned by the shared, visibility-gated timer.
|
|
738
|
+
if ((options.resolveLabel || options.resolveIcon) && handle) {
|
|
739
|
+
resolveAndPushLabel(agentId, workspaceId, handle);
|
|
740
|
+
}
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const cleanup = client.addComposerPill({
|
|
744
|
+
id: options.id,
|
|
745
|
+
title: options.title,
|
|
746
|
+
workspaceId,
|
|
747
|
+
agentId,
|
|
748
|
+
Component: PillHost,
|
|
749
|
+
onPress() {
|
|
750
|
+
const opener = openers.get(agentId);
|
|
751
|
+
if (opener) {
|
|
752
|
+
const defaultPayload = options.resolveDefaultPayload?.({ agentId, workspaceId });
|
|
753
|
+
opener(defaultPayload);
|
|
754
|
+
}
|
|
755
|
+
},
|
|
756
|
+
});
|
|
757
|
+
pills.set(agentId, { dispose: toCleanup(cleanup), workspaceId });
|
|
758
|
+
} catch (error) {
|
|
759
|
+
reportError(agentId, workspaceId, error);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function removePill(agentId: string) {
|
|
764
|
+
const entry = pills.get(agentId);
|
|
765
|
+
entry?.dispose();
|
|
766
|
+
pills.delete(agentId);
|
|
767
|
+
visiblePillMounts.delete(agentId);
|
|
768
|
+
iconValueByAgent.delete(agentId);
|
|
769
|
+
iconListenersByAgent.delete(agentId);
|
|
770
|
+
openers.delete(agentId);
|
|
771
|
+
syncSharedLabelTimer();
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const unsubscribe = client.paseo.agents.subscribe((update) => {
|
|
775
|
+
if (disposed) return;
|
|
776
|
+
if ("agentId" in update && update.kind === "remove") {
|
|
777
|
+
removePill(update.agentId);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
if ("agent" in update) {
|
|
781
|
+
const { id, workspaceId } = update.agent;
|
|
782
|
+
if (workspaceId) addPill(id, workspaceId);
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
client.paseo.agents
|
|
787
|
+
.list()
|
|
788
|
+
.then((result) => {
|
|
789
|
+
if (disposed) return;
|
|
790
|
+
for (const { agent } of result.entries) {
|
|
791
|
+
if (agent.workspaceId) addPill(agent.id, agent.workspaceId);
|
|
792
|
+
}
|
|
793
|
+
})
|
|
794
|
+
.catch((error) => {
|
|
795
|
+
// Silently dropping this leaves a plugin with zero pills and no clue why.
|
|
796
|
+
reportSuppressed(console, "registerComposerPill agents.list() failed", error, "warn");
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
return () => {
|
|
800
|
+
if (disposed) return;
|
|
801
|
+
disposed = true;
|
|
802
|
+
unsubscribe();
|
|
803
|
+
if (sharedLabelTimer) {
|
|
804
|
+
clearInterval(sharedLabelTimer);
|
|
805
|
+
sharedLabelTimer = null;
|
|
806
|
+
}
|
|
807
|
+
for (const entry of pills.values()) entry.dispose();
|
|
808
|
+
pills.clear();
|
|
809
|
+
visiblePillMounts.clear();
|
|
810
|
+
iconValueByAgent.clear();
|
|
811
|
+
iconListenersByAgent.clear();
|
|
812
|
+
openers.clear();
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
interface DefaultPillBodyProps {
|
|
817
|
+
title: string;
|
|
818
|
+
compactTitle?: string;
|
|
819
|
+
icon?: string;
|
|
820
|
+
compactIcon?: string;
|
|
821
|
+
badgeText?: string;
|
|
822
|
+
compactBadgeText?: string;
|
|
823
|
+
theme: HostPillProps["theme"];
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function DefaultPillBody({
|
|
827
|
+
title,
|
|
828
|
+
compactTitle,
|
|
829
|
+
icon,
|
|
830
|
+
compactIcon,
|
|
831
|
+
badgeText,
|
|
832
|
+
compactBadgeText,
|
|
833
|
+
theme,
|
|
834
|
+
}: DefaultPillBodyProps) {
|
|
835
|
+
const { Icon } = getClientHost();
|
|
836
|
+
const { isCompact } = useResponsive();
|
|
837
|
+
|
|
838
|
+
const effectiveTitle = isCompact && compactTitle ? compactTitle : title;
|
|
839
|
+
const effectiveIcon = isCompact && compactIcon ? compactIcon : icon;
|
|
840
|
+
const effectiveBadge =
|
|
841
|
+
isCompact && compactBadgeText !== undefined ? compactBadgeText : badgeText;
|
|
842
|
+
|
|
843
|
+
return (
|
|
844
|
+
<View style={styles.pillContainer}>
|
|
845
|
+
{effectiveIcon && <Icon name={effectiveIcon} size={13} color={theme.colors.foreground} />}
|
|
846
|
+
{effectiveTitle ? (
|
|
847
|
+
<Text style={[styles.title, { color: theme.colors.foreground }]}>{effectiveTitle}</Text>
|
|
848
|
+
) : null}
|
|
849
|
+
{effectiveBadge && (
|
|
850
|
+
<View style={[styles.badge, { backgroundColor: theme.colors.surface1 }]}>
|
|
851
|
+
<Text style={[styles.badgeText, { color: theme.colors.foregroundMuted }]}>
|
|
852
|
+
{effectiveBadge}
|
|
853
|
+
</Text>
|
|
854
|
+
</View>
|
|
855
|
+
)}
|
|
856
|
+
</View>
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const styles = StyleSheet.create({
|
|
861
|
+
popoverContainer: {
|
|
862
|
+
width: "100%",
|
|
863
|
+
},
|
|
864
|
+
pillContainer: {
|
|
865
|
+
flexDirection: "row",
|
|
866
|
+
alignItems: "center",
|
|
867
|
+
gap: 6,
|
|
868
|
+
paddingHorizontal: 8,
|
|
869
|
+
paddingVertical: 3,
|
|
870
|
+
},
|
|
871
|
+
title: {
|
|
872
|
+
fontSize: 12,
|
|
873
|
+
fontWeight: "500",
|
|
874
|
+
},
|
|
875
|
+
badge: {
|
|
876
|
+
borderRadius: 999,
|
|
877
|
+
paddingHorizontal: 5,
|
|
878
|
+
paddingVertical: 1,
|
|
879
|
+
},
|
|
880
|
+
badgeText: {
|
|
881
|
+
fontSize: 10,
|
|
882
|
+
fontWeight: "600",
|
|
883
|
+
},
|
|
884
|
+
});
|