@xpufx/paseo-top 0.4.3 → 0.4.4

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 (33) hide show
  1. package/client/pill-labels.ts +30 -6
  2. package/client/pill.tsx +40 -61
  3. package/client/settings-events.ts +33 -0
  4. package/client/surface.tsx +6 -2
  5. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +12 -0
  6. package/client/vendor/paseo-plugin-helper/components/Button.tsx +22 -2
  7. package/client/vendor/paseo-plugin-helper/components/Card.tsx +32 -0
  8. package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +17 -1
  9. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +14 -1
  10. package/client/vendor/paseo-plugin-helper/components/InteractiveRow.tsx +142 -0
  11. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +16 -2
  12. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +7 -0
  13. package/client/vendor/paseo-plugin-helper/components/Select.tsx +131 -63
  14. package/client/vendor/paseo-plugin-helper/components/index.ts +1 -0
  15. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +4 -3
  16. package/client/vendor/paseo-plugin-helper/host.ts +12 -58
  17. package/client/vendor/paseo-plugin-helper/index.ts +1 -0
  18. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +17 -27
  19. package/client/vendor/paseo-plugin-helper/pill.tsx +68 -321
  20. package/client/vendor/paseo-plugin-helper/surface.tsx +62 -2
  21. package/client/vendor/paseo-plugin-helper/tickets.tsx +766 -0
  22. package/index.server.ts +7 -0
  23. package/package.json +11 -2
  24. package/server/vendor/paseo-plugin-helper/index.ts +2 -0
  25. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +20 -2
  26. package/server/vendor/paseo-plugin-helper/plugins.ts +184 -19
  27. package/server/vendor/paseo-plugin-helper/tickets.ts +1281 -0
  28. package/shared/helper-version.ts +20 -0
  29. package/shared/multi-host.ts +11 -4
  30. package/shared/resources.ts +1 -1
  31. package/shared/vendor/paseo-plugin-helper/index.ts +1 -0
  32. package/shared/vendor/paseo-plugin-helper/tickets.ts +1010 -0
  33. package/shared/version.ts +1 -1
@@ -28,12 +28,10 @@ export interface ModalBodyProps {
28
28
  header?: ReactNode;
29
29
  headerStyle?: StyleProp<ViewStyle>;
30
30
  /**
31
- * Host dialog size preset. "default" (default) is fully fluid inside the
32
- * host-allocated dialog. "large" opts into the helper's documented wide
33
- * extent on desktop so data-dense modals/surfaces get room, and is ignored on
34
- * mobile (the bottom sheet is already full-bleed) and inside composer
35
- * popovers (the host owns that narrow viewport). Use this instead of adding a
36
- * per-plugin width/minWidth literal; the host still owns the final size.
31
+ * Advisory density hint. It carries no width floor: the host `Modal` has no
32
+ * size prop, so a content-side `minWidth` would override the host dialog
33
+ * allocation and clip inside narrow docks (paseo#641). Use `maxContentWidth`
34
+ * for a readable dense column. Ignored on compact/mobile surfaces.
37
35
  */
38
36
  size?: ModalBodySize;
39
37
  /**
@@ -92,9 +90,10 @@ export type ModalBodyScrollOwner = "helper" | "host" | "required" | "popover";
92
90
 
93
91
  export const ModalBodyScrollOwnerContext = createContext<ModalBodyScrollOwner>("helper");
94
92
 
95
- // The single documented home for the large dialog preset. Prefer
96
- // `ModalBody size="large"` over adding a per-plugin width/minWidth literal.
97
- const LARGE_DIALOG_MIN_WIDTH = 640;
93
+ // `size` is advisory and intentionally carries no width floor. A content-side
94
+ // `minWidth` overrides the host dialog allocation instead of deferring to it,
95
+ // so a narrow dock (explorer/sidebar) clips the content it was meant to widen
96
+ // (paseo#641). Use `maxContentWidth` to keep a dense column readable.
98
97
 
99
98
  /**
100
99
  * Mobile-safe scrollable body for Paseo <Modal.Content>.
@@ -142,7 +141,6 @@ export function ModalBody({
142
141
  header,
143
142
  headerStyle,
144
143
  headerMode = "scroll",
145
- size = "default",
146
144
  maxContentWidth,
147
145
  scrollMode = "auto",
148
146
  debugTag,
@@ -160,14 +158,11 @@ export function ModalBody({
160
158
  const helperOwnsScroll =
161
159
  !hostOwnsScroll &&
162
160
  (scrollOwner === "required" || scrollMode === "always" || isCompact || isMobile);
163
- // The large preset is the ONE documented place a plugin can ask for a wide
164
- // dialog frame. It is a desktop-only content minimum: the mobile sheet is
165
- // already full-bleed, and a composer popover viewport is host-owned and
166
- // intentionally narrow, so both ignore the preset.
167
- const sizeStyle =
168
- size === "large" && !isMobile && !isCompact && scrollOwner !== "popover"
169
- ? styles.largeDialog
170
- : undefined;
161
+ // `size` no longer contributes width. The host `Modal` has no size prop, so
162
+ // there is no host allocation to request and nothing to condition on: a
163
+ // content-side floor is the only lever available, and it fights the host by
164
+ // refusing to shrink inside a narrow dialog. Data-dense surfaces get their
165
+ // reading width from `maxContentWidth` on the content column instead.
171
166
  const ResolvedScrollView = selectHostScrollView(
172
167
  getOptionalClientHost(),
173
168
  FallbackScrollView as unknown as HostScrollView,
@@ -224,7 +219,7 @@ export function ModalBody({
224
219
  const scrollBody = (
225
220
  <ResolvedScrollView
226
221
  ref={setRefs}
227
- style={[{ backgroundColor: colors.surface0 }, styles.container, sizeStyle, style]}
222
+ style={[{ backgroundColor: colors.surface0 }, styles.container, style]}
228
223
  nestedScrollEnabled={true}
229
224
  keyboardShouldPersistTaps="handled"
230
225
  showsVerticalScrollIndicator={true}
@@ -265,7 +260,7 @@ export function ModalBody({
265
260
  } as unknown as ViewStyle)
266
261
  : undefined;
267
262
  const plainBody = (
268
- <View style={[styles.plainBody, sizeStyle, style]}>
263
+ <View style={[styles.plainBody, style]}>
269
264
  {header ? (
270
265
  <View style={[styles.header, stickyHeaderStyle, headerStyle]}>{header}</View>
271
266
  ) : null}
@@ -296,7 +291,7 @@ export function ModalBody({
296
291
  return (
297
292
  <ResolvedScrollView
298
293
  ref={setRefs}
299
- style={[{ backgroundColor: colors.surface0 }, styles.container, sizeStyle, style]}
294
+ style={[{ backgroundColor: colors.surface0 }, styles.container, style]}
300
295
  nestedScrollEnabled={true}
301
296
  keyboardShouldPersistTaps="handled"
302
297
  showsVerticalScrollIndicator={true}
@@ -330,7 +325,7 @@ export function ModalBody({
330
325
  }
331
326
 
332
327
  return (
333
- <View style={[styles.screen, sizeStyle]}>
328
+ <View style={[styles.screen]}>
334
329
  <View style={[styles.header, headerStyle]}>{header}</View>
335
330
  {scrollBody}
336
331
  </View>
@@ -338,11 +333,6 @@ export function ModalBody({
338
333
  }
339
334
 
340
335
  const styles = StyleSheet.create({
341
- // Desktop-only wide extent for `size="large"`. The host still owns the final
342
- // dialog size; this only asks a content-sizing host for more room.
343
- largeDialog: {
344
- minWidth: LARGE_DIALOG_MIN_WIDTH,
345
- },
346
336
  screen: {
347
337
  flex: 1,
348
338
  minHeight: 0,
@@ -2,35 +2,29 @@ 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
17
  } from "./host";
18
+ import type {
19
+ PluginButtonContentProps,
20
+ PluginButtonIconProps,
21
+ PluginButtonRegistration,
22
+ } from "@getpaseo/plugin/client";
21
23
  import { PluginThemeProvider } from "./theme/provider";
22
- import { useResponsive } from "./theme/useResponsive";
23
24
  import type { VisualFlair } from "./theme/flair";
24
25
  import { ModalBodyScrollOwnerContext } from "./layout/ModalBody";
25
26
  import { reportSuppressed } from "../../../shared/vendor/paseo-plugin-helper/suppressed";
26
27
 
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
28
  export interface RenderModalProps<TPayload = any> extends HostPillProps {
35
29
  close: () => void;
36
30
  payload?: TPayload;
@@ -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,6 +423,7 @@ export function registerComposerPill<TPayload = any>(
477
423
  onStartShouldSetResponder={() => true}
478
424
  onTouchEnd={stopBubbling}
479
425
  {...(eventBoundaryProps as any)}
426
+ style={{ flex: 1, minHeight: 0 }}
480
427
  >
481
428
  <PluginThemeProvider theme={theme} layout={layout} flair={options.flair}>
482
429
  <ModalBodyScrollOwnerContext.Provider
@@ -484,7 +431,7 @@ export function registerComposerPill<TPayload = any>(
484
431
  >
485
432
  {options.renderModal({
486
433
  agentId,
487
- workspaceId: props.workspaceId ?? "",
434
+ workspaceId: props.workspaceId,
488
435
  theme,
489
436
  layout,
490
437
  host: props.host ?? { id: "", label: "" },
@@ -502,7 +449,7 @@ export function registerComposerPill<TPayload = any>(
502
449
  };
503
450
  }
504
451
 
505
- // 0.8 popover scroll ownership: Paseo's MenuSurface/FloatingScrollView or
452
+ // Popover scroll ownership: Paseo's MenuSurface/FloatingScrollView or
506
453
  // BottomSheetScrollView already owns the viewport. Keep this wrapper plain,
507
454
  // unconstrained, and mark the subtree so ModalBody does not add a second
508
455
  // ScrollView. Fixed heights or overflow clipping here cut off mobile content.
@@ -562,98 +509,7 @@ export function registerComposerPill<TPayload = any>(
562
509
  );
563
510
  }
564
511
 
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;
512
+ function toCleanup(registration: PluginButtonRegistration): PluginCleanup {
657
513
  return () => registration.remove();
658
514
  }
659
515
 
@@ -675,9 +531,8 @@ export function registerComposerPill<TPayload = any>(
675
531
  function resolveAndPushLabel(
676
532
  agentId: string,
677
533
  workspaceId: string,
678
- registration: ComposerPillRegistration,
534
+ registration: PluginButtonRegistration,
679
535
  ): void {
680
- if (typeof registration === "function") return;
681
536
  if (!options.resolveLabel && !options.resolveIcon) return;
682
537
  const ctx = { agentId, workspaceId };
683
538
  Promise.all([
@@ -727,83 +582,41 @@ export function registerComposerPill<TPayload = any>(
727
582
  });
728
583
  }
729
584
 
730
- function detectShape(agentId: string, workspaceId: string): "button" | "legacy" {
731
- probeSequence += 1;
732
- const probeId = `php-probe-${probeSequence}`;
585
+ function addPill(agentId: string, workspaceId: string) {
586
+ if (disposed || pills.has(agentId)) return;
733
587
  try {
734
588
  const registration = client.addComposerPill({
735
- id: probeId,
589
+ id: options.id,
736
590
  workspaceId,
737
591
  agentId,
738
592
  button: {
739
- title: "probe",
740
- icon: "Activity",
741
- behavior: {
742
- kind: "action",
743
- onPress() {},
744
- },
593
+ title: options.title,
594
+ // The icon is both the visibility signal and the live-icon renderer.
595
+ icon: makePillIcon(agentId),
596
+ label: options.title,
597
+ behavior: options.onPress
598
+ ? { kind: "action", onPress: () => void options.onPress?.() }
599
+ : options.presentation === "centered"
600
+ ? {
601
+ kind: "action",
602
+ onPress: () => setCenteredOpen(agentId, !centeredOpenAgents.has(agentId)),
603
+ }
604
+ : {
605
+ kind: "popover",
606
+ Content: PillPopoverContent as ComponentType<PluginButtonContentProps>,
607
+ },
745
608
  },
746
609
  });
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,
610
+ pills.set(agentId, {
611
+ dispose: toCleanup(registration),
795
612
  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
- },
613
+ registration,
805
614
  });
806
- pills.set(agentId, { dispose: toCleanup(cleanup), workspaceId });
615
+ // Resolve once so the label is correct even before the icon mounts;
616
+ // ongoing polling is owned by the shared, visibility-gated timer.
617
+ if (options.resolveLabel || options.resolveIcon) {
618
+ resolveAndPushLabel(agentId, workspaceId, registration);
619
+ }
807
620
  } catch (error) {
808
621
  reportError(agentId, workspaceId, error);
809
622
  }
@@ -816,7 +629,6 @@ export function registerComposerPill<TPayload = any>(
816
629
  visiblePillMounts.delete(agentId);
817
630
  iconValueByAgent.delete(agentId);
818
631
  iconListenersByAgent.delete(agentId);
819
- openers.delete(agentId);
820
632
  syncSharedLabelTimer();
821
633
  }
822
634
 
@@ -858,76 +670,11 @@ export function registerComposerPill<TPayload = any>(
858
670
  visiblePillMounts.clear();
859
671
  iconValueByAgent.clear();
860
672
  iconListenersByAgent.clear();
861
- openers.clear();
862
673
  };
863
674
  }
864
675
 
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
676
  const styles = StyleSheet.create({
910
677
  popoverContainer: {
911
678
  width: "100%",
912
679
  },
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
680
  });