@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
@@ -1,9 +1,11 @@
1
- import React, { useState } from "react";
1
+ import React, { useRef, useState } from "react";
2
2
  import {
3
+ Modal,
3
4
  Pressable,
4
5
  ScrollView as FallbackScrollView,
5
6
  StyleSheet,
6
7
  Text,
8
+ TouchableWithoutFeedback,
7
9
  View,
8
10
  type StyleProp,
9
11
  type ViewStyle,
@@ -32,10 +34,25 @@ export interface SelectProps {
32
34
 
33
35
  const OPTION_LIST_MAX_HEIGHT = 216;
34
36
 
37
+ interface TriggerCoords {
38
+ x: number;
39
+ y: number;
40
+ width: number;
41
+ height: number;
42
+ }
43
+
44
+ // When `measureInWindow` is unavailable or never invokes its callback (node
45
+ // test renderers, host shims), the menu still opens anchored to a zeroed box
46
+ // rather than swallowing the press.
47
+ const FALLBACK_COORDS: TriggerCoords = { x: 0, y: 0, width: 0, height: 0 };
48
+
35
49
  /**
36
50
  * Compact single-choice picker sized to sit inside a {@link FormRow}. The
37
- * closed trigger stays one line tall; opening reveals a bounded, scrollable
38
- * option list, so a long list degrades to scrolling rather than overflow.
51
+ * closed trigger stays one line tall; opening mounts the menu in a root
52
+ * transparent `<Modal>` overlay portal, so a long list never expands the
53
+ * trigger's parent container, is never clipped by an ancestor
54
+ * `overflow: "hidden"` (Card/Tabs/Modal), and always paints above later
55
+ * siblings regardless of local stacking context.
39
56
  */
40
57
  export function Select({
41
58
  value,
@@ -48,12 +65,14 @@ export function Select({
48
65
  style,
49
66
  }: SelectProps) {
50
67
  const { Icon } = getClientHost();
51
- // The dropdown option list is a bounded box nested inside the host sheet,
52
- // so it scrolls with a plain React Native ScrollView — never the host
53
- // sheet-gesture scroller (#219).
68
+ // The option list is mounted in the root modal overlay, so it scrolls with
69
+ // a plain React Native ScrollView — never the host sheet-gesture scroller
70
+ // (#219) and never a scroller owned by the trigger's parent.
54
71
  const { colors, resolveRadius, typography, isCompact, touchTargetMin, alpha } =
55
72
  usePluginTheme();
56
73
  const [open, setOpen] = useState(false);
74
+ const [coords, setCoords] = useState<TriggerCoords | null>(null);
75
+ const triggerRef = useRef<View>(null);
57
76
 
58
77
  const caption = typography?.caption ?? {
59
78
  fontSize: 11,
@@ -76,14 +95,40 @@ export function Select({
76
95
 
77
96
  const triggerMinHeight = size === "sm" ? 28 : Math.max(34, touchTargetMin);
78
97
 
98
+ const openAt = (next: TriggerCoords) => {
99
+ setCoords(next);
100
+ setOpen(true);
101
+ };
102
+
103
+ const handleToggle = () => {
104
+ if (isOpen) {
105
+ setOpen(false);
106
+ return;
107
+ }
108
+
109
+ const node = triggerRef.current;
110
+ // A node/shim without `measureInWindow` (node test renderers, stripped
111
+ // host shims) still opens anchored to a zeroed box rather than swallowing
112
+ // the press (#520).
113
+ if (!node || typeof node.measureInWindow !== "function") {
114
+ openAt(FALLBACK_COORDS);
115
+ return;
116
+ }
117
+
118
+ node.measureInWindow((x, y, width, height) => {
119
+ openAt({ x, y, width, height });
120
+ });
121
+ };
122
+
79
123
  return (
80
124
  <View style={[styles.container, style]}>
81
125
  <Pressable
126
+ ref={triggerRef}
82
127
  accessibilityRole="button"
83
128
  accessibilityLabel={label ? `${label}: ${display}` : display}
84
129
  accessibilityState={{ expanded: isOpen, disabled }}
85
130
  disabled={!canOpen}
86
- onPress={() => setOpen((prev) => !prev)}
131
+ onPress={handleToggle}
87
132
  style={({ pressed }) => [
88
133
  styles.trigger,
89
134
  {
@@ -122,67 +167,84 @@ export function Select({
122
167
  />
123
168
  </Pressable>
124
169
 
125
- {isOpen ? (
126
- <View
127
- style={[
128
- styles.optionList,
129
- {
130
- borderRadius: radius,
131
- borderColor: colors.border,
132
- backgroundColor: colors.surface0,
133
- marginTop: spacing.xs,
134
- },
135
- ]}
170
+ {isOpen && coords ? (
171
+ <Modal
172
+ transparent
173
+ visible={isOpen}
174
+ animationType="none"
175
+ onRequestClose={() => setOpen(false)}
136
176
  >
137
- <FallbackScrollView
138
- nestedScrollEnabled
139
- keyboardShouldPersistTaps="handled"
140
- style={{ maxHeight: OPTION_LIST_MAX_HEIGHT }}
177
+ <TouchableWithoutFeedback
178
+ accessibilityRole="button"
179
+ accessibilityLabel="Dismiss options"
180
+ onPress={() => setOpen(false)}
181
+ >
182
+ <View style={StyleSheet.absoluteFill} />
183
+ </TouchableWithoutFeedback>
184
+
185
+ <View
186
+ style={[
187
+ styles.overlayList,
188
+ {
189
+ top: coords.y + coords.height + spacing.xs,
190
+ left: coords.x,
191
+ minWidth: coords.width,
192
+ borderRadius: radius,
193
+ borderColor: colors.border,
194
+ backgroundColor: colors.surface0,
195
+ },
196
+ ]}
141
197
  >
142
- {options.map((option) => {
143
- const isSelected = option.value === value;
144
- return (
145
- <Pressable
146
- key={option.value}
147
- accessibilityRole="button"
148
- accessibilityLabel={option.label}
149
- accessibilityState={{ selected: isSelected }}
150
- onPress={() => {
151
- onValueChange(option.value);
152
- setOpen(false);
153
- }}
154
- style={({ pressed }) => [
155
- styles.option,
156
- {
157
- paddingVertical,
158
- paddingHorizontal,
159
- backgroundColor: isSelected
160
- ? colors.surface2
161
- : pressed
162
- ? alpha(colors.surface2, 0.5)
163
- : "transparent",
164
- },
165
- ]}
166
- >
167
- <Text
168
- numberOfLines={1}
169
- style={[
170
- styles.optionText,
198
+ <FallbackScrollView
199
+ nestedScrollEnabled
200
+ keyboardShouldPersistTaps="handled"
201
+ style={{ maxHeight: OPTION_LIST_MAX_HEIGHT }}
202
+ >
203
+ {options.map((option) => {
204
+ const isSelected = option.value === value;
205
+ return (
206
+ <Pressable
207
+ key={option.value}
208
+ accessibilityRole="button"
209
+ accessibilityLabel={option.label}
210
+ accessibilityState={{ selected: isSelected }}
211
+ onPress={() => {
212
+ onValueChange(option.value);
213
+ setOpen(false);
214
+ }}
215
+ style={({ pressed }) => [
216
+ styles.option,
171
217
  {
172
- color: isSelected ? colors.foreground : colors.foregroundMuted,
173
- fontSize: isCompact ? fontSize : fontSize + 1,
174
- lineHeight,
175
- fontWeight: isSelected ? "600" : "400",
218
+ paddingVertical,
219
+ paddingHorizontal,
220
+ backgroundColor: isSelected
221
+ ? colors.surface2
222
+ : pressed
223
+ ? alpha(colors.surface2, 0.5)
224
+ : "transparent",
176
225
  },
177
226
  ]}
178
227
  >
179
- {option.label}
180
- </Text>
181
- </Pressable>
182
- );
183
- })}
184
- </FallbackScrollView>
185
- </View>
228
+ <Text
229
+ numberOfLines={1}
230
+ style={[
231
+ styles.optionText,
232
+ {
233
+ color: isSelected ? colors.foreground : colors.foregroundMuted,
234
+ fontSize: isCompact ? fontSize : fontSize + 1,
235
+ lineHeight,
236
+ fontWeight: isSelected ? "600" : "400",
237
+ },
238
+ ]}
239
+ >
240
+ {option.label}
241
+ </Text>
242
+ </Pressable>
243
+ );
244
+ })}
245
+ </FallbackScrollView>
246
+ </View>
247
+ </Modal>
186
248
  ) : null}
187
249
  </View>
188
250
  );
@@ -203,9 +265,15 @@ const styles = StyleSheet.create({
203
265
  flexShrink: 1,
204
266
  minWidth: 0,
205
267
  },
206
- optionList: {
268
+ overlayList: {
269
+ position: "absolute",
207
270
  borderWidth: 1,
208
271
  overflow: "hidden",
272
+ elevation: 20,
273
+ shadowColor: "#000",
274
+ shadowOffset: { width: 0, height: 4 },
275
+ shadowOpacity: 0.25,
276
+ shadowRadius: 8,
209
277
  },
210
278
  option: {},
211
279
  optionText: {
@@ -1,5 +1,6 @@
1
1
  export * from "./Button";
2
2
  export * from "./InlineButton";
3
+ export * from "./InteractiveRow";
3
4
  export * from "./AttentionBeacon";
4
5
  export * from "./Badge";
5
6
  export * from "./StatusDot";
@@ -17,8 +17,10 @@ export interface CustomPillBodyProps {
17
17
  }
18
18
 
19
19
  /**
20
- * Standard pill body renderer for a custom metric pill in the composer trackbar.
21
- * Automatically adapts to responsive compact/mobile modes and shows threshold status.
20
+ * Standard pill body renderer for a custom metric pill. The host renders the
21
+ * composer pill body itself, so reach for this from a pill's own surface
22
+ * (`renderModal`) rather than from the trackbar; it adapts to responsive
23
+ * compact/mobile modes and shows threshold status.
22
24
  */
23
25
  export function CustomPillBody({ state }: CustomPillBodyProps) {
24
26
  const { Icon } = getClientHost();
@@ -145,7 +147,6 @@ export function registerCustomPills(
145
147
  flair: options.flair,
146
148
  resolveLabel: () =>
147
149
  pill.displayValue ? `${pill.title} ${pill.displayValue}` : pill.title,
148
- renderPill: () => <CustomPillBody state={pill} />,
149
150
  renderModal: () => {
150
151
  const [refreshing, setRefreshing] = useState(false);
151
152
  const [currentOutput, setCurrentOutput] = useState<string | undefined>(
@@ -14,20 +14,23 @@ import type {
14
14
  TextInput as RNTextInput,
15
15
  TextInputProps,
16
16
  } from "react-native";
17
+ import type {
18
+ PluginButtonRegistration,
19
+ PluginComposerPillContribution,
20
+ } from "@getpaseo/plugin/client";
17
21
 
18
22
  /**
19
23
  * Structural host types for Paseo client integration.
20
24
  *
21
25
  * These interfaces describe the shapes `paseo-plugin-helper/client` needs
22
- * from the Paseo host app. They are intentionally decoupled from any
23
- * Paseo SDK version: this module (and every client module built on
24
- * it) contains zero Paseo SDK imports, so a single published helper
25
- * bundle satisfies both the Paseo v0.7 SDK entry points and the Paseo
26
- * v0.8 runtime-owned entry points.
26
+ * from the Paseo host app: the runtime dependencies the plugin injects once
27
+ * in its client entry — the host theme, the layout, the icon/Modal primitives,
28
+ * the agents feed — so they can be supplied from any entry point.
27
29
  *
28
- * The plugin author provides the real host implementations once, in the
29
- * client entry, using whichever specifiers match their installed SDK.
30
- * See docs/client.md for the per-version import paths.
30
+ * The composer pill is the exception: the host owns that contribution's shape,
31
+ * so it is imported from the Paseo SDK rather than restated here. A restated
32
+ * copy is what let a pre-0.9 `{Component, onPress}` pill through the type
33
+ * system (#666).
31
34
  */
32
35
 
33
36
  export interface HostThemeColors {
@@ -216,57 +219,8 @@ export interface HostSurfaceProps {
216
219
  };
217
220
  }
218
221
 
219
- export interface ComposerPillContribution {
220
- id: string;
221
- title: string;
222
- workspaceId: string;
223
- agentId: string;
224
- Component: ComponentType<HostPillProps>;
225
- onPress(): void | Promise<void>;
226
- }
227
-
228
- export type ComposerPillButtonIcon = string | ComponentType<any>;
229
-
230
- export interface ComposerPillButtonDescriptor {
231
- title: string;
232
- icon: ComposerPillButtonIcon;
233
- label?: string;
234
- visible?: boolean;
235
- disabled?: boolean;
236
- behavior:
237
- | { kind: "action"; onPress(): void | Promise<void> }
238
- | { kind: "popover"; Content: ComponentType<any> };
239
- }
240
-
241
- export interface ComposerPillButtonContribution {
242
- id: string;
243
- workspaceId: string;
244
- agentId: string;
245
- button: ComposerPillButtonDescriptor;
246
- }
247
-
248
- export interface ComposerPillSdkContribution {
249
- id: string;
250
- workspaceId: string;
251
- agentId: string;
252
- button: Record<string, any>;
253
- [key: string]: any;
254
- }
255
-
256
- export interface ComposerPillRegistrationHandle {
257
- update(patch: Record<string, any>): void;
258
- remove(): void;
259
- }
260
-
261
- export type ComposerPillRegistration = PluginCleanup | ComposerPillRegistrationHandle;
262
-
263
222
  export interface ComposerPillRegistrar {
264
- addComposerPill(
265
- contribution:
266
- | ComposerPillContribution
267
- | ComposerPillButtonContribution
268
- | ComposerPillSdkContribution,
269
- ): ComposerPillRegistration;
223
+ addComposerPill(contribution: PluginComposerPillContribution): PluginButtonRegistration;
270
224
  paseo: {
271
225
  agents: HostAgentsApi;
272
226
  };
@@ -1,4 +1,5 @@
1
1
  export * from "./theme/index";
2
+ export * from "./styles/index";
2
3
  export * from "./components/index";
3
4
  export * from "./layout/index";
4
5
  export * from "./pill";
@@ -26,3 +27,4 @@ export {
26
27
  type ForgeMarkInput,
27
28
  type ResolvedForgeMark,
28
29
  } from "../../../shared/vendor/paseo-plugin-helper/forge";
30
+ export * from "./tickets";
@@ -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
  /**
@@ -88,13 +86,14 @@ export interface ModalBodyProps {
88
86
  * Adding the `"required"` member is additive: the existing two values keep
89
87
  * their meaning and the default stays `"helper"`.
90
88
  */
91
- export type ModalBodyScrollOwner = "helper" | "host" | "required";
89
+ 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,
@@ -154,20 +152,17 @@ export function ModalBody({
154
152
  }: ModalBodyProps) {
155
153
  const { isCompact, isMobile, layout, padding, colors } = usePluginTheme();
156
154
  const scrollOwner = useContext(ModalBodyScrollOwnerContext);
157
- const hostOwnsScroll = scrollOwner === "host";
155
+ const hostOwnsScroll = scrollOwner === "host" || scrollOwner === "popover";
158
156
  // "required": an ancestor proved the host supplies no scroller (plugin
159
157
  // surface) — own the scroll on every surface, including non-compact desktop.
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 && !hostOwnsScroll
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,
@@ -1,35 +1,33 @@
1
1
  import React, { type ReactNode } from "react";
2
2
  import { getClientHost } from "../host";
3
- import { ModalBody, type ModalBodyProps } from "./ModalBody";
3
+ import { ModalBody, ModalBodyScrollOwnerContext, type ModalBodyProps } from "./ModalBody";
4
4
 
5
5
  export interface ModalContentProps extends Omit<ModalBodyProps, "scrollMode"> {
6
6
  children: ReactNode;
7
+ /**
8
+ * Whether the host owns the outer scroll container.
9
+ * Defaults to `true` so host desktop mouse wheel, trackpad, and mobile bottom
10
+ * sheet gestures scroll natively without fighting an inner scroller.
11
+ * Set to `false` only if the modal contains a custom internal scroller
12
+ * (e.g. virtualized list or canvas).
13
+ */
14
+ scrollable?: boolean;
7
15
  }
8
16
 
9
17
  /**
10
18
  * Helper-owned modal body for plugins that open their own host `<Modal>`.
11
19
  *
12
- * Use this instead of the raw host `<Modal.Content>`: it wraps the host content
13
- * view AND the shared `ModalBody` contract in one element, so a plugin cannot
14
- * accidentally end up content-sized.
20
+ * Delegates scroll ownership to the host `<Modal.Content>` (scrollable by default)
21
+ * so desktop mouse wheel, trackpad, and mobile bottom sheet gestures scroll
22
+ * natively under Paseo host rules without fighting an inner scroller.
15
23
  *
16
- * Why the raw host `Modal.Content` resizes: Paseo maps
17
- * `<Modal.Content scrollable={true}>` (the host default) to a desktop card with
18
- * no explicit height, so the dialog grows/shrinks with its children on every
19
- * data change. This wrapper always passes `scrollable={false}`, which makes the
20
- * host allocate a bounded dialog (`desktopHeight: "85%"`). Because that host
21
- * content view then supplies no scroller, the wrapper also forces
22
- * `ModalBody scrollMode="always"`, so the helper owns the one scroll region on
23
- * every surface and the bounded dialog scrolls instead of clipping.
24
- *
25
- * The size contract is `ModalBody`'s: it takes the host-allocated dialog size
26
- * and is fluid within it; `size?: "default" | "large"` is the only size escape
27
- * hatch. Do not add per-plugin width/minWidth/height literals around it.
24
+ * Sizing stays fluid within the host-allocated modal frame, respecting
25
+ * `size?: "default" | "large"`.
28
26
  *
29
27
  * ```tsx
30
28
  * <Modal title="…" open={open} onOpenChange={setOpen}>
31
29
  * <ModalContent size="default">
32
- * …cards, controls, rows; content never drives the dialog frame…
30
+ * …cards, controls, rows…
33
31
  * </ModalContent>
34
32
  * </Modal>
35
33
  * ```
@@ -37,13 +35,17 @@ export interface ModalContentProps extends Omit<ModalBodyProps, "scrollMode"> {
37
35
  * Accepts every `ModalBody` prop (header, headerMode, refreshing, onRefresh,
38
36
  * stickToEnd, scrollRef, debugTag, style, contentContainerStyle, …).
39
37
  */
40
- export function ModalContent({ children, ...bodyProps }: ModalContentProps) {
38
+ export function ModalContent({
39
+ children,
40
+ scrollable = true,
41
+ ...bodyProps
42
+ }: ModalContentProps) {
41
43
  const { Modal } = getClientHost();
42
44
  return (
43
- <Modal.Content scrollable={false}>
44
- <ModalBody scrollMode="always" {...bodyProps}>
45
- {children}
46
- </ModalBody>
45
+ <Modal.Content scrollable={scrollable}>
46
+ <ModalBodyScrollOwnerContext.Provider value={scrollable ? "host" : "required"}>
47
+ <ModalBody {...bodyProps}>{children}</ModalBody>
48
+ </ModalBodyScrollOwnerContext.Provider>
47
49
  </Modal.Content>
48
50
  );
49
51
  }
@@ -20,6 +20,8 @@ export interface RegisterWorkspacePanelOptions {
20
20
  icon: string;
21
21
  Component: ComponentType<PluginWorkspacePanelProps>;
22
22
  flair?: VisualFlair;
23
+ /** Host locations in which the panel should be available. */
24
+ locations?: string[];
23
25
  }
24
26
 
25
27
  export interface RegisterAgentPanelOptions {
@@ -37,8 +39,8 @@ export interface RegisterAgentPanelOptions {
37
39
  export function registerWorkspacePanel(
38
40
  plugin: WorkspacePanelRegistrar,
39
41
  options: RegisterWorkspacePanelOptions,
40
- ): void {
41
- const { id, title, icon, Component, flair } = options;
42
+ ): () => void {
43
+ const { id, title, icon, Component, flair, locations } = options;
42
44
 
43
45
  const WrappedComponent: ComponentType<PluginWorkspacePanelProps> = (props) => (
44
46
  <PluginThemeProvider theme={props.theme} layout={props.layout} flair={flair}>
@@ -46,13 +48,19 @@ export function registerWorkspacePanel(
46
48
  </PluginThemeProvider>
47
49
  );
48
50
 
49
- plugin.addWorkspacePanel({
51
+ const registration = plugin.addWorkspacePanel({
50
52
  id,
51
53
  title,
52
54
  icon,
53
55
  context: "workspace",
56
+ locations,
54
57
  Component: WrappedComponent,
55
58
  });
59
+
60
+ return () => {
61
+ if (typeof registration === "function") registration();
62
+ else registration?.remove?.();
63
+ };
56
64
  }
57
65
 
58
66
  /**