@xpufx/paseo-top 0.4.3 → 0.4.5

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.
Files changed (130) hide show
  1. package/OVERVIEW.md +17 -0
  2. package/client/host-ui.tsx +184 -0
  3. package/client/multi-host-view.tsx +57 -43
  4. package/client/multi-host.ts +36 -13
  5. package/client/pill-labels.ts +124 -7
  6. package/client/pill.tsx +724 -669
  7. package/client/resources-query.ts +1 -1
  8. package/client/settings-events.ts +33 -0
  9. package/client/settings-ui.tsx +86 -9
  10. package/client/surface.tsx +310 -274
  11. package/client/telemetry-copy.ts +8 -2
  12. package/client/telemetry.tsx +372 -321
  13. package/client/turn-panel.tsx +126 -104
  14. package/client/vendor/paseo-plugin-helper/{host.ts → core/host.ts} +21 -61
  15. package/client/vendor/paseo-plugin-helper/core/index.ts +76 -0
  16. package/client/vendor/paseo-plugin-helper/core/plugin-load-count.ts +89 -0
  17. package/client/vendor/paseo-plugin-helper/{query-refresh.ts → core/query-refresh.ts} +2 -2
  18. package/client/vendor/paseo-plugin-helper/{query.ts → core/query.ts} +2 -2
  19. package/client/vendor/paseo-plugin-helper/{settings.ts → core/settings.ts} +1 -1
  20. package/client/vendor/paseo-plugin-helper/{shared-settings.ts → core/shared-settings.ts} +2 -2
  21. package/client/vendor/paseo-plugin-helper/{utils → lifecycle}/clipboard.ts +2 -2
  22. package/client/vendor/paseo-plugin-helper/lifecycle/flair.ts +26 -0
  23. package/client/vendor/paseo-plugin-helper/{theme/color-utils.ts → lifecycle/host-color.ts} +95 -38
  24. package/client/vendor/paseo-plugin-helper/lifecycle/host-theme.tsx +109 -0
  25. package/client/vendor/paseo-plugin-helper/lifecycle/index.ts +23 -0
  26. package/client/vendor/paseo-plugin-helper/lifecycle/multi-host.ts +66 -0
  27. package/client/vendor/paseo-plugin-helper/{panel.tsx → lifecycle/panel.tsx} +7 -7
  28. package/client/vendor/paseo-plugin-helper/{pill.tsx → lifecycle/pill.tsx} +118 -338
  29. package/client/vendor/paseo-plugin-helper/lifecycle/providers.tsx +46 -0
  30. package/client/vendor/paseo-plugin-helper/lifecycle/scroll-owner.ts +26 -0
  31. package/client/vendor/paseo-plugin-helper/lifecycle/surface.tsx +140 -0
  32. package/client/vendor/paseo-plugin-helper/ui/color.ts +1 -0
  33. package/client/vendor/paseo-plugin-helper/ui/content.tsx +1326 -0
  34. package/client/vendor/paseo-plugin-helper/ui/controls.tsx +1018 -0
  35. package/client/vendor/paseo-plugin-helper/{components/DataTable.tsx → ui/data.tsx} +55 -47
  36. package/client/vendor/paseo-plugin-helper/ui/index.ts +44 -0
  37. package/client/vendor/paseo-plugin-helper/ui/layout.tsx +263 -0
  38. package/client/vendor/paseo-plugin-helper/ui/modal.tsx +111 -0
  39. package/client/vendor/paseo-plugin-helper/ui/plugin-reload-banner.tsx +119 -0
  40. package/client/vendor/paseo-plugin-helper/{settings-screen.tsx → ui/settings-screen.tsx} +3 -3
  41. package/client/vendor/paseo-plugin-helper/ui/settings.ts +36 -0
  42. package/client/vendor/paseo-plugin-helper/ui/text.tsx +821 -0
  43. package/client/vendor/paseo-plugin-helper/ui/theme.tsx +1 -0
  44. package/client/vendor/permission-audit/client.tsx +706 -0
  45. package/client/vendor/permission-audit/index.tsx +1 -0
  46. package/index.client.tsx +58 -37
  47. package/index.server.ts +256 -227
  48. package/package.json +14 -2
  49. package/paseo-plugin.json +2 -0
  50. package/screenshots/1-paseo-top-modal-system.jpg +0 -0
  51. package/screenshots/2-paseo-top-modal-workspace.jpg +0 -0
  52. package/screenshots/3-paseo-top-modal-settings.jpg +0 -0
  53. package/screenshots/4-paseo-top-modal-about.jpg +0 -0
  54. package/screenshots/paseo-top-dashboard.jpg +0 -0
  55. package/screenshots/paseo-top-general-view.jpg +0 -0
  56. package/server/resources.ts +98 -0
  57. package/server/vendor/paseo-plugin-helper/custom-pills.ts +70 -0
  58. package/server/vendor/paseo-plugin-helper/index.ts +4 -0
  59. package/server/vendor/paseo-plugin-helper/logger.ts +5 -16
  60. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +20 -2
  61. package/server/vendor/paseo-plugin-helper/plugins.ts +184 -19
  62. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +2 -2
  63. package/server/vendor/paseo-plugin-helper/storage-guard.ts +294 -0
  64. package/server/vendor/paseo-plugin-helper/storage.ts +36 -2
  65. package/server/vendor/paseo-plugin-helper/telemetry.ts +486 -0
  66. package/server/vendor/paseo-plugin-helper/tickets.ts +1232 -0
  67. package/server/vendor/permission-audit/index.ts +1 -0
  68. package/server/vendor/permission-audit/server.ts +839 -0
  69. package/shared/helper-version.ts +20 -0
  70. package/shared/multi-host.ts +156 -155
  71. package/shared/resources.ts +146 -1
  72. package/shared/vendor/paseo-plugin-helper/async.ts +14 -7
  73. package/shared/vendor/paseo-plugin-helper/features.ts +244 -0
  74. package/shared/vendor/paseo-plugin-helper/forge.ts +27 -0
  75. package/shared/vendor/paseo-plugin-helper/index.ts +2 -0
  76. package/shared/vendor/paseo-plugin-helper/tickets.ts +1104 -0
  77. package/shared/vendor/permission-audit/filter.ts +61 -0
  78. package/shared/vendor/permission-audit/index.ts +2 -0
  79. package/shared/vendor/permission-audit/shared.ts +119 -0
  80. package/shared/version.ts +1 -1
  81. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +0 -493
  82. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +0 -250
  83. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +0 -156
  84. package/client/vendor/paseo-plugin-helper/components/Button.tsx +0 -184
  85. package/client/vendor/paseo-plugin-helper/components/Card.tsx +0 -225
  86. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +0 -196
  87. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +0 -277
  88. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +0 -172
  89. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +0 -180
  90. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +0 -97
  91. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +0 -70
  92. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +0 -75
  93. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +0 -446
  94. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +0 -247
  95. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +0 -117
  96. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +0 -53
  97. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +0 -118
  98. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +0 -80
  99. package/client/vendor/paseo-plugin-helper/components/Select.tsx +0 -215
  100. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +0 -80
  101. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +0 -319
  102. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +0 -150
  103. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +0 -163
  104. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +0 -157
  105. package/client/vendor/paseo-plugin-helper/components/index.ts +0 -25
  106. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +0 -224
  107. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +0 -79
  108. package/client/vendor/paseo-plugin-helper/icon.tsx +0 -39
  109. package/client/vendor/paseo-plugin-helper/index.ts +0 -29
  110. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +0 -49
  111. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +0 -103
  112. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +0 -65
  113. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +0 -378
  114. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +0 -51
  115. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +0 -39
  116. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +0 -39
  117. package/client/vendor/paseo-plugin-helper/layout/index.ts +0 -7
  118. package/client/vendor/paseo-plugin-helper/styles/index.ts +0 -1
  119. package/client/vendor/paseo-plugin-helper/styles/recipes.ts +0 -550
  120. package/client/vendor/paseo-plugin-helper/surface.tsx +0 -80
  121. package/client/vendor/paseo-plugin-helper/theme/flair.ts +0 -76
  122. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +0 -121
  123. package/client/vendor/paseo-plugin-helper/theme/index.ts +0 -7
  124. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +0 -178
  125. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +0 -213
  126. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +0 -200
  127. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +0 -57
  128. /package/client/vendor/paseo-plugin-helper/{command-center.ts → core/command-center.ts} +0 -0
  129. /package/client/vendor/paseo-plugin-helper/{snapshot.ts → core/snapshot.ts} +0 -0
  130. /package/client/vendor/paseo-plugin-helper/{utils → lifecycle}/haptics.ts +0 -0
@@ -2,34 +2,28 @@ import React, {
2
2
  useEffect,
3
3
  useLayoutEffect,
4
4
  useMemo,
5
- useState,
6
5
  useSyncExternalStore,
7
6
  type ComponentType,
8
7
  type ReactNode,
9
8
  } from "react";
10
- import { StyleSheet, Text, useWindowDimensions, View } from "react-native";
9
+ import { StyleSheet, useWindowDimensions, View } from "react-native";
11
10
  import {
12
11
  getClientHost,
13
12
  type ComposerPillRegistrar,
14
- type ComposerPillRegistration,
15
- type ComposerPillRegistrationHandle,
16
13
  type HostLayout,
17
14
  type HostPillProps,
18
15
  type HostTheme,
19
16
  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
- }
17
+ } from "../core/host";
18
+ import type {
19
+ PluginButtonContentProps,
20
+ PluginButtonIconProps,
21
+ PluginButtonRegistration,
22
+ } from "@getpaseo/plugin/client";
23
+ import type { VisualFlair } from "./flair";
24
+ import { ModalBodyScrollOwnerContext } from "./scroll-owner";
25
+ import { RegistrarThemeScope } from "./providers";
26
+ import { reportSuppressed } from "../../../../shared/vendor/paseo-plugin-helper/suppressed";
33
27
 
34
28
  export interface RenderModalProps<TPayload = any> extends HostPillProps {
35
29
  close: () => void;
@@ -69,8 +63,6 @@ export function resolvePillModalScrollable(hostScroll?: boolean): boolean {
69
63
  return hostScroll === true;
70
64
  }
71
65
 
72
- let probeSequence = 0;
73
-
74
66
  const NOOP_SUBSCRIBE = (): (() => void) => () => {};
75
67
 
76
68
  export interface RegisterComposerPillOptions<TPayload = any> {
@@ -118,48 +110,25 @@ export interface RegisterComposerPillOptions<TPayload = any> {
118
110
  flair?: Partial<VisualFlair>;
119
111
 
120
112
  /**
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.
113
+ * Resolves the live pill label (and optionally icon). The host renders the
114
+ * pill body itself, so this is the only channel for live pill text. Called
115
+ * once at registration and then every `refreshIntervalMs`. Keep it cheap and
116
+ * synchronous when possible; async resolvers are awaited. Returning
117
+ * `undefined` leaves the current label/icon.
148
118
  * Can return a plain string (label) or an object `{ label?: string; icon?: string }`.
149
119
  * Cycle modes can advance rotation state on each call.
150
120
  */
151
121
  resolveLabel?: PillLabelResolver;
152
122
 
153
123
  /**
154
- * Optional standalone resolver for the button icon on button-shaped hosts (Paseo 0.8+).
155
- * Evaluated alongside `resolveLabel` on each tick.
124
+ * Optional standalone resolver for the pill icon. Evaluated alongside
125
+ * `resolveLabel` on each tick.
156
126
  */
157
127
  resolveIcon?: PillIconResolver;
158
128
 
159
129
  /**
160
- * Poll interval for `resolveLabel` on button-shaped hosts. Defaults to 5000ms
130
+ * Poll interval for `resolveLabel`. Defaults to 5000ms
161
131
  * 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
132
  */
164
133
  refreshIntervalMs?: number;
165
134
 
@@ -178,31 +147,28 @@ export interface RegisterComposerPillOptions<TPayload = any> {
178
147
  popoverWidth?: number;
179
148
 
180
149
  /**
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.
150
+ * Renders the content inside the pill's surface.
151
+ * The host owns the surface: with the default `"popover"` presentation it
152
+ * anchors this output to the pill at the host surface width (expect a
153
+ * narrow column, not a wide modal; keep content vertically stacked and
154
+ * reflowing), and with `"centered"` it renders it from the pill's icon.
155
+ * Live pill text therefore comes from `resolveLabel`, not from this renderer.
188
156
  *
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
157
+ * Never render a host `<Modal.Content>` here — use `HostModalSection` from
192
158
  * `paseo-plugin-helper/ui` for fluid content. (`HostModalContent` is only
193
159
  * for plugins that open their OWN host `<Modal>`.)
194
160
  */
195
161
  renderModal?: (props: RenderModalProps<TPayload>) => ReactNode;
196
162
 
197
163
  /**
198
- * Host-owned scroll for the pill modal paths (#219).
199
- * - `false` (default, legacy): the wrapper renders
164
+ * Host-owned scroll for the centered modal path (#219).
165
+ * - `false` (default): the wrapper renders
200
166
  * `<Modal.Content scrollable={false}>` (bounded dialog) and
201
167
  * `ModalBody`-based content owns the one scroller.
202
168
  * - `true`: the wrapper renders `<Modal.Content scrollable={true}>` so the
203
169
  * host scrolls, and `renderModal` must provide fluid content with NO
204
170
  * nested `<Modal.Content>` or scroller (`HostModalSection`).
205
- * Exactly one `<Modal.Content>` is rendered in both modes. The 0.8 popover
171
+ * Exactly one `<Modal.Content>` is rendered in both modes. The popover
206
172
  * path is unaffected (plain host-owned container either way).
207
173
  */
208
174
  hostScroll?: boolean;
@@ -228,9 +194,9 @@ export interface RegisterComposerPillOptions<TPayload = any> {
228
194
  presentation?: "popover" | "centered";
229
195
 
230
196
  /**
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.
197
+ * Called when a pill cannot be registered on the current host. Reporting
198
+ * instead of throwing keeps the rest of the plugin client alive; render the
199
+ * message in your own panel to make it visible.
234
200
  */
235
201
  onError?: (info: { agentId: string; workspaceId: string; error: Error }) => void;
236
202
  }
@@ -238,32 +204,27 @@ export interface RegisterComposerPillOptions<TPayload = any> {
238
204
  /**
239
205
  * Registers an agent-scoped composer pill and modal lifecycle.
240
206
  * 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
207
  */
246
208
  export function registerComposerPill<TPayload = any>(
247
209
  client: ComposerPillRegistrar,
248
210
  options: RegisterComposerPillOptions<TPayload>,
249
211
  ): PluginCleanup {
250
212
  const { Icon, Modal } = getClientHost();
251
- const openers = new Map<string, (payload?: TPayload) => void>();
252
213
  const pills = new Map<
253
214
  string,
254
215
  {
255
216
  dispose: () => void;
256
217
  workspaceId: string;
257
- registration?: ComposerPillRegistrationHandle;
218
+ registration?: PluginButtonRegistration;
258
219
  }
259
220
  >();
260
221
  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.
222
+ // A pill can only be resolved while the host actually renders it. The host
223
+ // mounts the pill's custom icon component for every visible composer pill, and
224
+ // that mount is the plugin's only visibility signal: its mount/unmount
225
+ // brackets the pill's on-screen lifetime. Polling every registered agent
226
+ // regardless of that signal ran one ticker per agent in the install, which
227
+ // stampedes the daemon on a host with many agents.
267
228
  const visiblePillMounts = new Map<string, number>();
268
229
  // Agent ids whose anchored popover is currently mounted. While a popover is
269
230
  // open the host re-renders the whole composer on every agent-stream message;
@@ -280,7 +241,6 @@ export function registerComposerPill<TPayload = any>(
280
241
  const centeredOpenAgents = new Set<string>();
281
242
  const centeredListenersByAgent = new Map<string, Set<() => void>>();
282
243
  let sharedLabelTimer: ReturnType<typeof setInterval> | null = null;
283
- let detectedShape: "button" | "legacy" | null = null;
284
244
  let disposed = false;
285
245
 
286
246
  function defaultPillIcon(): string {
@@ -385,22 +345,8 @@ export function registerComposerPill<TPayload = any>(
385
345
  * `registration.update({ icon })`, so the probe component never gets
386
346
  * replaced by a plain string and the mount signal survives every icon swap.
387
347
  */
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
- }) {
348
+ function makePillIcon(agentId: string): ComponentType<PluginButtonIconProps> {
349
+ return function PillVisibilityIcon(props: PluginButtonIconProps) {
404
350
  const subscribe = useMemo(() => subscribePillIcon(agentId), [agentId]);
405
351
  const name = useSyncExternalStore(
406
352
  subscribe,
@@ -426,7 +372,7 @@ export function registerComposerPill<TPayload = any>(
426
372
 
427
373
  const icon = <Icon name={name} size={props.size ?? 14} color={props.color ?? ""} />;
428
374
  const { theme, layout } = props;
429
- if (!centered || !options.renderModal || !theme || !layout) return icon;
375
+ if (!centered || !options.renderModal) return icon;
430
376
 
431
377
  const modalIconElement = React.isValidElement(options.modalIcon) ? (
432
378
  options.modalIcon
@@ -477,21 +423,26 @@ export function registerComposerPill<TPayload = any>(
477
423
  onStartShouldSetResponder={() => true}
478
424
  onTouchEnd={stopBubbling}
479
425
  {...(eventBoundaryProps as any)}
426
+ style={
427
+ resolvePillModalScrollable(options.hostScroll)
428
+ ? { width: "100%" }
429
+ : { flex: 1, minHeight: 0, width: "100%" }
430
+ }
480
431
  >
481
- <PluginThemeProvider theme={theme} layout={layout} flair={options.flair}>
432
+ <RegistrarThemeScope theme={theme} layout={layout} flair={options.flair}>
482
433
  <ModalBodyScrollOwnerContext.Provider
483
434
  value={resolvePillModalScrollable(options.hostScroll) ? "host" : "required"}
484
435
  >
485
436
  {options.renderModal({
486
437
  agentId,
487
- workspaceId: props.workspaceId ?? "",
438
+ workspaceId: props.workspaceId,
488
439
  theme,
489
440
  layout,
490
441
  host: props.host ?? { id: "", label: "" },
491
442
  close: () => setCenteredOpen(agentId, false),
492
443
  })}
493
444
  </ModalBodyScrollOwnerContext.Provider>
494
- </PluginThemeProvider>
445
+ </RegistrarThemeScope>
495
446
  </View>
496
447
  ) : null}
497
448
  </Modal.Content>
@@ -502,7 +453,7 @@ export function registerComposerPill<TPayload = any>(
502
453
  };
503
454
  }
504
455
 
505
- // 0.8 popover scroll ownership: Paseo's MenuSurface/FloatingScrollView or
456
+ // Popover scroll ownership: Paseo's MenuSurface/FloatingScrollView or
506
457
  // BottomSheetScrollView already owns the viewport. Keep this wrapper plain,
507
458
  // unconstrained, and mark the subtree so ModalBody does not add a second
508
459
  // ScrollView. Fixed heights or overflow clipping here cut off mobile content.
@@ -523,11 +474,14 @@ export function registerComposerPill<TPayload = any>(
523
474
  !props.layout.compact && options.popoverWidth
524
475
  ? Math.max(0, Math.min(options.popoverWidth, windowWidth - 24))
525
476
  : undefined;
526
- // The host remains the scroll owner, but it needs a finite child extent to
527
- // establish the popover viewport. Do not add overflow clipping or an inner
528
- // ScrollView here: either would compete with the host's popover/sheet
529
- // scroller.
530
- const frameMaxHeight = Math.max(1, Math.min(560, windowHeight * 0.8));
477
+ // The host remains the scroll owner, but desktop needs a finite child extent to
478
+ // establish the popover viewport. Compact mobile presents a full-bleed sheet
479
+ // whose BottomSheetScrollView dynamically sizes and scrolls, so capping height
480
+ // here would clip long content instead of scrolling it.
481
+ const isMobile = props.layout.platform === "ios" || props.layout.platform === "android";
482
+ const frameMaxHeight = props.layout.compact && isMobile
483
+ ? undefined
484
+ : Math.max(1, Math.min(560, windowHeight * 0.8));
531
485
  // Layout effect, not effect: the flag must be set in the same commit the
532
486
  // popover mounts, before any label timer can interleave. An effect runs
533
487
  // after paint, leaving a window where one tick can still publish and
@@ -546,7 +500,7 @@ export function registerComposerPill<TPayload = any>(
546
500
  host: props.host ?? { id: "", label: "" },
547
501
  };
548
502
  return (
549
- <PluginThemeProvider theme={props.theme} layout={props.layout} flair={options.flair}>
503
+ <RegistrarThemeScope theme={props.theme} layout={props.layout} flair={options.flair}>
550
504
  <View
551
505
  style={{
552
506
  ...styles.popoverContainer,
@@ -558,102 +512,11 @@ export function registerComposerPill<TPayload = any>(
558
512
  {options.renderModal?.({ ...pillProps, close: props.close })}
559
513
  </ModalBodyScrollOwnerContext.Provider>
560
514
  </View>
561
- </PluginThemeProvider>
515
+ </RegistrarThemeScope>
562
516
  );
563
517
  }
564
518
 
565
- function PillHost(props: HostPillProps) {
566
- const [open, setOpen] = useState(false);
567
- const [payload, setPayload] = useState<TPayload | undefined>(undefined);
568
-
569
- useEffect(() => {
570
- openers.set(props.agentId, (incomingPayload?: TPayload) => {
571
- setPayload(incomingPayload);
572
- setOpen(true);
573
- });
574
- return () => {
575
- openers.delete(props.agentId);
576
- };
577
- }, [props.agentId]);
578
-
579
- const effectiveModalTitle = options.modalTitle ?? options.title;
580
-
581
- const modalIconElement = useMemo(() => {
582
- if (React.isValidElement(options.modalIcon)) {
583
- return options.modalIcon;
584
- }
585
- const iconName =
586
- typeof options.modalIcon === "string" ? options.modalIcon : options.icon;
587
- if (iconName) {
588
- return <Icon name={iconName} size={16} color={props.theme.colors.foreground} />;
589
- }
590
- return undefined;
591
- }, [options.modalIcon, options.icon, props.theme.colors.foreground]);
592
-
593
- const renderPillProps: RenderPillProps<TPayload> = {
594
- ...props,
595
- isOpen: open,
596
- open: (customPayload?: TPayload) => {
597
- setPayload(customPayload);
598
- setOpen(true);
599
- },
600
- close: () => setOpen(false),
601
- toggle: (customPayload?: TPayload) => {
602
- setPayload(customPayload);
603
- setOpen((prev) => !prev);
604
- },
605
- };
606
-
607
- return (
608
- <PluginThemeProvider theme={props.theme} layout={props.layout} flair={options.flair}>
609
- {options.renderPill ? (
610
- options.renderPill(renderPillProps)
611
- ) : (
612
- <DefaultPillBody
613
- title={options.title}
614
- compactTitle={options.compactTitle}
615
- icon={options.icon}
616
- compactIcon={options.compactIcon}
617
- badgeText={options.badgeText}
618
- compactBadgeText={options.compactBadgeText}
619
- theme={props.theme}
620
- />
621
- )}
622
-
623
- <Modal
624
- title={effectiveModalTitle}
625
- icon={modalIconElement}
626
- open={open}
627
- onOpenChange={(nextOpen) => {
628
- setOpen(nextOpen);
629
- if (!nextOpen) {
630
- setPayload(undefined);
631
- }
632
- }}
633
- >
634
- <Modal.Content scrollable={resolvePillModalScrollable(options.hostScroll)}>
635
- {open ? (
636
- <PluginThemeProvider theme={props.theme} layout={props.layout} flair={options.flair}>
637
- <ModalBodyScrollOwnerContext.Provider
638
- value={resolvePillModalScrollable(options.hostScroll) ? "host" : "required"}
639
- >
640
- {options.renderModal?.({
641
- ...props,
642
- close: () => setOpen(false),
643
- payload,
644
- })}
645
- </ModalBodyScrollOwnerContext.Provider>
646
- </PluginThemeProvider>
647
- ) : null}
648
- </Modal.Content>
649
-
650
- </Modal>
651
- </PluginThemeProvider>
652
- );
653
- }
654
-
655
- function toCleanup(registration: ComposerPillRegistration): PluginCleanup {
656
- if (typeof registration === "function") return registration;
519
+ function toCleanup(registration: PluginButtonRegistration): PluginCleanup {
657
520
  return () => registration.remove();
658
521
  }
659
522
 
@@ -675,9 +538,8 @@ export function registerComposerPill<TPayload = any>(
675
538
  function resolveAndPushLabel(
676
539
  agentId: string,
677
540
  workspaceId: string,
678
- registration: ComposerPillRegistration,
541
+ registration: PluginButtonRegistration,
679
542
  ): void {
680
- if (typeof registration === "function") return;
681
543
  if (!options.resolveLabel && !options.resolveIcon) return;
682
544
  const ctx = { agentId, workspaceId };
683
545
  Promise.all([
@@ -727,83 +589,41 @@ export function registerComposerPill<TPayload = any>(
727
589
  });
728
590
  }
729
591
 
730
- function detectShape(agentId: string, workspaceId: string): "button" | "legacy" {
731
- probeSequence += 1;
732
- const probeId = `php-probe-${probeSequence}`;
592
+ function addPill(agentId: string, workspaceId: string) {
593
+ if (disposed || pills.has(agentId)) return;
733
594
  try {
734
595
  const registration = client.addComposerPill({
735
- id: probeId,
596
+ id: options.id,
736
597
  workspaceId,
737
598
  agentId,
738
599
  button: {
739
- title: "probe",
740
- icon: "Activity",
741
- behavior: {
742
- kind: "action",
743
- onPress() {},
744
- },
600
+ title: options.title,
601
+ // The icon is both the visibility signal and the live-icon renderer.
602
+ icon: makePillIcon(agentId),
603
+ label: options.title,
604
+ behavior: options.onPress
605
+ ? { kind: "action", onPress: () => void options.onPress?.() }
606
+ : options.presentation === "centered"
607
+ ? {
608
+ kind: "action",
609
+ onPress: () => setCenteredOpen(agentId, !centeredOpenAgents.has(agentId)),
610
+ }
611
+ : {
612
+ kind: "popover",
613
+ Content: PillPopoverContent as ComponentType<PluginButtonContentProps>,
614
+ },
745
615
  },
746
616
  });
747
- toCleanup(registration)();
748
- return "button";
749
- } catch {
750
- return "legacy";
751
- }
752
- }
753
-
754
- function addPill(agentId: string, workspaceId: string) {
755
- if (disposed || pills.has(agentId)) return;
756
- try {
757
- if (!detectedShape) {
758
- detectedShape = detectShape(agentId, workspaceId);
759
- }
760
- if (detectedShape === "button") {
761
- const registration = client.addComposerPill({
762
- id: options.id,
763
- workspaceId,
764
- agentId,
765
- button: {
766
- title: options.title,
767
- // The probe is the visibility signal and the live-icon renderer.
768
- icon: makePillIcon(agentId),
769
- label: options.title,
770
- behavior: options.onPress
771
- ? { kind: "action", onPress: () => void options.onPress?.() }
772
- : options.presentation === "centered"
773
- ? {
774
- kind: "action",
775
- onPress: () => setCenteredOpen(agentId, !centeredOpenAgents.has(agentId)),
776
- }
777
- : {
778
- kind: "popover",
779
- Content: PillPopoverContent as ComponentType<any>,
780
- },
781
- },
782
- });
783
- const handle = typeof registration === "function" ? undefined : registration;
784
- pills.set(agentId, { dispose: toCleanup(registration), workspaceId, registration: handle });
785
- // Resolve once so the label is correct even before the probe mounts;
786
- // ongoing polling is owned by the shared, visibility-gated timer.
787
- if ((options.resolveLabel || options.resolveIcon) && handle) {
788
- resolveAndPushLabel(agentId, workspaceId, handle);
789
- }
790
- return;
791
- }
792
- const cleanup = client.addComposerPill({
793
- id: options.id,
794
- title: options.title,
617
+ pills.set(agentId, {
618
+ dispose: toCleanup(registration),
795
619
  workspaceId,
796
- agentId,
797
- Component: PillHost,
798
- onPress() {
799
- const opener = openers.get(agentId);
800
- if (opener) {
801
- const defaultPayload = options.resolveDefaultPayload?.({ agentId, workspaceId });
802
- opener(defaultPayload);
803
- }
804
- },
620
+ registration,
805
621
  });
806
- pills.set(agentId, { dispose: toCleanup(cleanup), workspaceId });
622
+ // Resolve once so the label is correct even before the icon mounts;
623
+ // ongoing polling is owned by the shared, visibility-gated timer.
624
+ if (options.resolveLabel || options.resolveIcon) {
625
+ resolveAndPushLabel(agentId, workspaceId, registration);
626
+ }
807
627
  } catch (error) {
808
628
  reportError(agentId, workspaceId, error);
809
629
  }
@@ -816,10 +636,11 @@ export function registerComposerPill<TPayload = any>(
816
636
  visiblePillMounts.delete(agentId);
817
637
  iconValueByAgent.delete(agentId);
818
638
  iconListenersByAgent.delete(agentId);
819
- openers.delete(agentId);
820
639
  syncSharedLabelTimer();
821
640
  }
822
641
 
642
+ const lifetime = new AbortController();
643
+
823
644
  const unsubscribe = client.paseo.agents.subscribe((update) => {
824
645
  if (disposed) return;
825
646
  if ("agentId" in update && update.kind === "remove") {
@@ -833,14 +654,37 @@ export function registerComposerPill<TPayload = any>(
833
654
  });
834
655
 
835
656
  client.paseo.agents
836
- .list()
657
+ .list({ subscribe: {}, signal: lifetime.signal })
837
658
  .then((result) => {
838
659
  if (disposed) return;
839
- for (const { agent } of result.entries) {
840
- if (agent.workspaceId) addPill(agent.id, agent.workspaceId);
660
+ if (result.entries) {
661
+ for (const { agent } of result.entries) {
662
+ if (agent.workspaceId) addPill(agent.id, agent.workspaceId);
663
+ }
664
+ }
665
+ if (result.subscription) {
666
+ result.subscription.subscribe({
667
+ snapshot: ({ entries }: { entries: Array<{ agent: { id: string; workspaceId?: string | null } }> }) => {
668
+ if (disposed) return;
669
+ for (const { agent } of entries) {
670
+ if (agent.workspaceId) addPill(agent.id, agent.workspaceId);
671
+ }
672
+ },
673
+ update: (message: any) => {
674
+ if (disposed) return;
675
+ if (message?.type !== "agent_update") return;
676
+ const update = message.payload;
677
+ if ("agentId" in update && update.kind === "remove") {
678
+ removePill(update.agentId);
679
+ } else if ("agent" in update && update.agent?.workspaceId) {
680
+ addPill(update.agent.id, update.agent.workspaceId);
681
+ }
682
+ },
683
+ });
841
684
  }
842
685
  })
843
686
  .catch((error) => {
687
+ if (lifetime.signal.aborted || disposed) return;
844
688
  // Silently dropping this leaves a plugin with zero pills and no clue why.
845
689
  reportSuppressed(console, "registerComposerPill agents.list() failed", error, "warn");
846
690
  });
@@ -848,6 +692,7 @@ export function registerComposerPill<TPayload = any>(
848
692
  return () => {
849
693
  if (disposed) return;
850
694
  disposed = true;
695
+ lifetime.abort();
851
696
  unsubscribe();
852
697
  if (sharedLabelTimer) {
853
698
  clearInterval(sharedLabelTimer);
@@ -858,76 +703,11 @@ export function registerComposerPill<TPayload = any>(
858
703
  visiblePillMounts.clear();
859
704
  iconValueByAgent.clear();
860
705
  iconListenersByAgent.clear();
861
- openers.clear();
862
706
  };
863
707
  }
864
708
 
865
- interface DefaultPillBodyProps {
866
- title: string;
867
- compactTitle?: string;
868
- icon?: string;
869
- compactIcon?: string;
870
- badgeText?: string;
871
- compactBadgeText?: string;
872
- theme: HostPillProps["theme"];
873
- }
874
-
875
- function DefaultPillBody({
876
- title,
877
- compactTitle,
878
- icon,
879
- compactIcon,
880
- badgeText,
881
- compactBadgeText,
882
- theme,
883
- }: DefaultPillBodyProps) {
884
- const { Icon } = getClientHost();
885
- const { isCompact } = useResponsive();
886
-
887
- const effectiveTitle = isCompact && compactTitle ? compactTitle : title;
888
- const effectiveIcon = isCompact && compactIcon ? compactIcon : icon;
889
- const effectiveBadge =
890
- isCompact && compactBadgeText !== undefined ? compactBadgeText : badgeText;
891
-
892
- return (
893
- <View style={styles.pillContainer}>
894
- {effectiveIcon && <Icon name={effectiveIcon} size={13} color={theme.colors.foreground} />}
895
- {effectiveTitle ? (
896
- <Text style={[styles.title, { color: theme.colors.foreground }]}>{effectiveTitle}</Text>
897
- ) : null}
898
- {effectiveBadge && (
899
- <View style={[styles.badge, { backgroundColor: theme.colors.surface1 }]}>
900
- <Text style={[styles.badgeText, { color: theme.colors.foregroundMuted }]}>
901
- {effectiveBadge}
902
- </Text>
903
- </View>
904
- )}
905
- </View>
906
- );
907
- }
908
-
909
709
  const styles = StyleSheet.create({
910
710
  popoverContainer: {
911
711
  width: "100%",
912
712
  },
913
- pillContainer: {
914
- flexDirection: "row",
915
- alignItems: "center",
916
- gap: 6,
917
- paddingHorizontal: 8,
918
- paddingVertical: 3,
919
- },
920
- title: {
921
- fontSize: 12,
922
- fontWeight: "500",
923
- },
924
- badge: {
925
- borderRadius: 999,
926
- paddingHorizontal: 5,
927
- paddingVertical: 1,
928
- },
929
- badgeText: {
930
- fontSize: 10,
931
- fontWeight: "600",
932
- },
933
713
  });