@xpufx/paseo-top 0.4.2 → 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 (43) hide show
  1. package/README.md +11 -12
  2. package/client/multi-host-view.tsx +177 -0
  3. package/client/multi-host.ts +98 -0
  4. package/client/pill-labels.ts +30 -6
  5. package/client/pill.tsx +42 -61
  6. package/client/settings-events.ts +33 -0
  7. package/client/surface.tsx +10 -9
  8. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +12 -0
  9. package/client/vendor/paseo-plugin-helper/components/Button.tsx +29 -3
  10. package/client/vendor/paseo-plugin-helper/components/Card.tsx +32 -0
  11. package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +17 -1
  12. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +17 -2
  13. package/client/vendor/paseo-plugin-helper/components/InteractiveRow.tsx +142 -0
  14. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +16 -2
  15. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +7 -0
  16. package/client/vendor/paseo-plugin-helper/components/Select.tsx +131 -63
  17. package/client/vendor/paseo-plugin-helper/components/index.ts +1 -0
  18. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +4 -3
  19. package/client/vendor/paseo-plugin-helper/host.ts +12 -58
  20. package/client/vendor/paseo-plugin-helper/index.ts +2 -0
  21. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +19 -29
  22. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +24 -22
  23. package/client/vendor/paseo-plugin-helper/panel.tsx +11 -3
  24. package/client/vendor/paseo-plugin-helper/pill.tsx +135 -339
  25. package/client/vendor/paseo-plugin-helper/styles/index.ts +1 -0
  26. package/client/vendor/paseo-plugin-helper/styles/recipes.ts +550 -0
  27. package/client/vendor/paseo-plugin-helper/surface.tsx +62 -2
  28. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +3 -39
  29. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +40 -1
  30. package/client/vendor/paseo-plugin-helper/tickets.tsx +766 -0
  31. package/index.server.ts +7 -0
  32. package/package.json +12 -3
  33. package/paseo-plugin.json +1 -1
  34. package/server/vendor/paseo-plugin-helper/index.ts +2 -0
  35. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +20 -2
  36. package/server/vendor/paseo-plugin-helper/plugins.ts +184 -19
  37. package/server/vendor/paseo-plugin-helper/tickets.ts +1281 -0
  38. package/shared/helper-version.ts +20 -0
  39. package/shared/multi-host.ts +348 -0
  40. package/shared/resources.ts +1 -1
  41. package/shared/vendor/paseo-plugin-helper/index.ts +1 -0
  42. package/shared/vendor/paseo-plugin-helper/tickets.ts +1010 -0
  43. package/shared/version.ts +1 -1
@@ -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
@@ -439,36 +385,71 @@ export function registerComposerPill<TPayload = any>(
439
385
  color={theme.colors.foreground}
440
386
  />
441
387
  );
388
+ const stopBubbling = (e: any) => {
389
+ if (e && typeof e.stopPropagation === "function") {
390
+ e.stopPropagation();
391
+ }
392
+ };
393
+
394
+ const eventBoundaryProps = {
395
+ onClick: stopBubbling,
396
+ onMouseDown: stopBubbling,
397
+ onMouseUp: stopBubbling,
398
+ onPointerDown: stopBubbling,
399
+ onPointerUp: stopBubbling,
400
+ onTouchStart: stopBubbling,
401
+ onTouchEnd: stopBubbling,
402
+ onKeyDown: stopBubbling,
403
+ onKeyUp: stopBubbling,
404
+ };
405
+
442
406
  return (
443
407
  <>
444
408
  {icon}
445
- <Modal
446
- title={options.modalTitle ?? options.title}
447
- icon={modalIconElement}
448
- open={centeredOpen}
449
- onOpenChange={(nextOpen: boolean) => setCenteredOpen(agentId, nextOpen)}
409
+ <View
410
+ onStartShouldSetResponder={() => true}
411
+ onTouchEnd={stopBubbling}
412
+ {...(eventBoundaryProps as any)}
450
413
  >
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>
414
+ <Modal
415
+ title={options.modalTitle ?? options.title}
416
+ icon={modalIconElement}
417
+ open={centeredOpen}
418
+ onOpenChange={(nextOpen: boolean) => setCenteredOpen(agentId, nextOpen)}
419
+ >
420
+ <Modal.Content scrollable={resolvePillModalScrollable(options.hostScroll)}>
421
+ {centeredOpen ? (
422
+ <View
423
+ onStartShouldSetResponder={() => true}
424
+ onTouchEnd={stopBubbling}
425
+ {...(eventBoundaryProps as any)}
426
+ style={{ flex: 1, minHeight: 0 }}
427
+ >
428
+ <PluginThemeProvider theme={theme} layout={layout} flair={options.flair}>
429
+ <ModalBodyScrollOwnerContext.Provider
430
+ value={resolvePillModalScrollable(options.hostScroll) ? "host" : "required"}
431
+ >
432
+ {options.renderModal({
433
+ agentId,
434
+ workspaceId: props.workspaceId,
435
+ theme,
436
+ layout,
437
+ host: props.host ?? { id: "", label: "" },
438
+ close: () => setCenteredOpen(agentId, false),
439
+ })}
440
+ </ModalBodyScrollOwnerContext.Provider>
441
+ </PluginThemeProvider>
442
+ </View>
443
+ ) : null}
444
+ </Modal.Content>
445
+ </Modal>
446
+ </View>
466
447
  </>
467
448
  );
468
449
  };
469
450
  }
470
451
 
471
- // 0.8 popover scroll ownership: Paseo's MenuSurface/FloatingScrollView or
452
+ // Popover scroll ownership: Paseo's MenuSurface/FloatingScrollView or
472
453
  // BottomSheetScrollView already owns the viewport. Keep this wrapper plain,
473
454
  // unconstrained, and mark the subtree so ModalBody does not add a second
474
455
  // ScrollView. Fixed heights or overflow clipping here cut off mobile content.
@@ -480,7 +461,7 @@ export function registerComposerPill<TPayload = any>(
480
461
  host?: { id: string; label: string };
481
462
  close: () => void;
482
463
  }) {
483
- const { width: windowWidth } = useWindowDimensions();
464
+ const { width: windowWidth, height: windowHeight } = useWindowDimensions();
484
465
  // The anchored popover is otherwise sized from its content, so reflowing
485
466
  // content can move the frame under the pointer. Pin it to an explicit width
486
467
  // (clamped to the viewport) and let children overflow into their own
@@ -489,6 +470,11 @@ export function registerComposerPill<TPayload = any>(
489
470
  !props.layout.compact && options.popoverWidth
490
471
  ? Math.max(0, Math.min(options.popoverWidth, windowWidth - 24))
491
472
  : undefined;
473
+ // The host remains the scroll owner, but it needs a finite child extent to
474
+ // establish the popover viewport. Do not add overflow clipping or an inner
475
+ // ScrollView here: either would compete with the host's popover/sheet
476
+ // scroller.
477
+ const frameMaxHeight = Math.max(1, Math.min(560, windowHeight * 0.8));
492
478
  // Layout effect, not effect: the flag must be set in the same commit the
493
479
  // popover mounts, before any label timer can interleave. An effect runs
494
480
  // after paint, leaving a window where one tick can still publish and
@@ -508,8 +494,14 @@ export function registerComposerPill<TPayload = any>(
508
494
  };
509
495
  return (
510
496
  <PluginThemeProvider theme={props.theme} layout={props.layout} flair={options.flair}>
511
- <View style={[styles.popoverContainer, frameWidth ? { width: frameWidth } : null]}>
512
- <ModalBodyScrollOwnerContext.Provider value="host">
497
+ <View
498
+ style={{
499
+ ...styles.popoverContainer,
500
+ maxHeight: frameMaxHeight,
501
+ ...(frameWidth ? { width: frameWidth } : null),
502
+ }}
503
+ >
504
+ <ModalBodyScrollOwnerContext.Provider value="popover">
513
505
  {options.renderModal?.({ ...pillProps, close: props.close })}
514
506
  </ModalBodyScrollOwnerContext.Provider>
515
507
  </View>
@@ -517,94 +509,7 @@ export function registerComposerPill<TPayload = any>(
517
509
  );
518
510
  }
519
511
 
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;
512
+ function toCleanup(registration: PluginButtonRegistration): PluginCleanup {
608
513
  return () => registration.remove();
609
514
  }
610
515
 
@@ -626,9 +531,8 @@ export function registerComposerPill<TPayload = any>(
626
531
  function resolveAndPushLabel(
627
532
  agentId: string,
628
533
  workspaceId: string,
629
- registration: ComposerPillRegistration,
534
+ registration: PluginButtonRegistration,
630
535
  ): void {
631
- if (typeof registration === "function") return;
632
536
  if (!options.resolveLabel && !options.resolveIcon) return;
633
537
  const ctx = { agentId, workspaceId };
634
538
  Promise.all([
@@ -678,83 +582,41 @@ export function registerComposerPill<TPayload = any>(
678
582
  });
679
583
  }
680
584
 
681
- function detectShape(agentId: string, workspaceId: string): "button" | "legacy" {
682
- probeSequence += 1;
683
- const probeId = `php-probe-${probeSequence}`;
585
+ function addPill(agentId: string, workspaceId: string) {
586
+ if (disposed || pills.has(agentId)) return;
684
587
  try {
685
588
  const registration = client.addComposerPill({
686
- id: probeId,
589
+ id: options.id,
687
590
  workspaceId,
688
591
  agentId,
689
592
  button: {
690
- title: "probe",
691
- icon: "Activity",
692
- behavior: {
693
- kind: "action",
694
- onPress() {},
695
- },
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
+ },
696
608
  },
697
609
  });
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,
610
+ pills.set(agentId, {
611
+ dispose: toCleanup(registration),
746
612
  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
- },
613
+ registration,
756
614
  });
757
- 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
+ }
758
620
  } catch (error) {
759
621
  reportError(agentId, workspaceId, error);
760
622
  }
@@ -767,7 +629,6 @@ export function registerComposerPill<TPayload = any>(
767
629
  visiblePillMounts.delete(agentId);
768
630
  iconValueByAgent.delete(agentId);
769
631
  iconListenersByAgent.delete(agentId);
770
- openers.delete(agentId);
771
632
  syncSharedLabelTimer();
772
633
  }
773
634
 
@@ -809,76 +670,11 @@ export function registerComposerPill<TPayload = any>(
809
670
  visiblePillMounts.clear();
810
671
  iconValueByAgent.clear();
811
672
  iconListenersByAgent.clear();
812
- openers.clear();
813
673
  };
814
674
  }
815
675
 
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
676
  const styles = StyleSheet.create({
861
677
  popoverContainer: {
862
678
  width: "100%",
863
679
  },
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
680
  });
@@ -0,0 +1 @@
1
+ export * from "./recipes";