@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
@@ -56,6 +56,20 @@ export interface SegmentSnapshot {
56
56
  worktreeLocationText?: string;
57
57
  }
58
58
 
59
+ /**
60
+ * First non-blank string wins, else the fallback. `??` only guards null and
61
+ * undefined, so a present-but-empty host field (blank `title`, `provider`,
62
+ * `branch`, `agentId`) slipped through as an empty label. On a button-shaped
63
+ * host an empty label renders icon-only, which reads as the pill collapsing to
64
+ * a skinny/empty line (xpufx-org/paseo#507).
65
+ */
66
+ function firstNonBlank(fallback: string, ...values: Array<string | null | undefined>): string {
67
+ for (const value of values) {
68
+ if (typeof value === "string" && value.trim().length > 0) return value;
69
+ }
70
+ return fallback;
71
+ }
72
+
59
73
  export interface TokenMetrics {
60
74
  inputTokens?: number;
61
75
  outputTokens?: number;
@@ -262,28 +276,38 @@ export function describeSegment(
262
276
  const prefix = def?.shortLabel ? `${def.shortLabel} ` : "";
263
277
  switch (item) {
264
278
  case "branch":
265
- return { icon, iconTone: "accent", text: data?.branch ?? "--", tone: "foreground" };
279
+ return {
280
+ icon,
281
+ iconTone: "accent",
282
+ text: firstNonBlank("--", data?.branch),
283
+ tone: "foreground",
284
+ };
266
285
  case "worktree":
267
286
  return {
268
287
  icon,
269
288
  iconTone: "accent",
270
- text: worktreeLocationText || "--",
289
+ text: firstNonBlank("--", worktreeLocationText),
271
290
  tone: "foreground",
272
291
  };
273
292
  case "agent_title":
274
- return { icon, iconTone: "accent", text: agent?.title ?? "Agent", tone: "foreground" };
293
+ return {
294
+ icon,
295
+ iconTone: "accent",
296
+ text: firstNonBlank("Agent", agent?.title),
297
+ tone: "foreground",
298
+ };
275
299
  case "agent":
276
300
  return {
277
301
  icon,
278
302
  iconTone: "accent",
279
- text: agent?.model || agent?.provider || "Agent",
303
+ text: firstNonBlank("Agent", agent?.model, agent?.provider),
280
304
  tone: "foreground",
281
305
  };
282
306
  case "agent_provider":
283
307
  return {
284
308
  icon,
285
309
  iconTone: "accent",
286
- text: agent?.provider ?? "Provider",
310
+ text: firstNonBlank("Provider", agent?.provider),
287
311
  tone: "foreground",
288
312
  };
289
313
  case "agent_activity": {
@@ -299,7 +323,7 @@ export function describeSegment(
299
323
  return {
300
324
  icon,
301
325
  iconTone: "accent",
302
- text: agentId && agentId.length > 7 ? agentId.slice(0, 7) : agentId ?? "--",
326
+ text: agentId && agentId.length > 7 ? agentId.slice(0, 7) : firstNonBlank("--", agentId),
303
327
  tone: "foreground",
304
328
  };
305
329
  case "load":
package/client/pill.tsx CHANGED
@@ -28,16 +28,14 @@ import {
28
28
  Badge,
29
29
  Icon,
30
30
  AboutSection,
31
- CustomPillBody,
32
31
  CustomPillModalContent,
33
32
  useRpcQuery,
34
33
  usePluginSettings,
35
34
  usePluginTheme,
36
35
  getStatusColor,
37
36
  triggerHaptic,
38
- shouldEmitSnapshotUpdate,
37
+ type HostPillProps,
39
38
  type RenderModalProps,
40
- type RenderPillProps,
41
39
  type KeyValueProps,
42
40
  type CardHeaderProps,
43
41
  type BadgeProps,
@@ -47,8 +45,8 @@ import {
47
45
  formatUptime,
48
46
  resolveMetricStatus,
49
47
  type MetricThresholds,
50
- type CustomPillState,
51
48
  } from "../shared/vendor/paseo-plugin-helper/index";
49
+ import { PermissionAuditView } from "permission-audit/client";
52
50
  import {
53
51
  getSystemResourcesRpc,
54
52
  topSettingsContract,
@@ -72,6 +70,7 @@ import {
72
70
  } from "../shared/resources";
73
71
  import { PLUGIN_VERSION } from "../shared/version";
74
72
  import { TopDashboardSurface } from "./surface";
73
+ import { addSettingsListener, removeSettingsListener, notifySettingsChanged } from "./settings-events";
75
74
  import { useTopResourceQuery, useCustomPillsQuery } from "./resources-query";
76
75
  import { ChoiceChips } from "./settings-ui";
77
76
  import {
@@ -140,6 +139,7 @@ const MEM_THRESHOLDS: MetricThresholds = { warning: 70, danger: 85 };
140
139
  const TABS = [
141
140
  { id: "system", label: "System", shortLabel: "System", icon: "Activity" },
142
141
  { id: "context", label: "Workspace", shortLabel: "Workspace", icon: "GitBranch" },
142
+ { id: "permissions", label: "Permissions", shortLabel: "Permissions", icon: "ShieldCheck" },
143
143
  { id: "settings", label: "Settings", shortLabel: "Settings", icon: "Sliders" },
144
144
  { id: "about", label: "About", shortLabel: "About", icon: "Info" },
145
145
  ];
@@ -177,7 +177,7 @@ function getMetricColors(
177
177
  // so pill call sites keep a single import path.
178
178
  export type { PillItemType } from "./pill-labels";
179
179
 
180
- export type ModalTab = "system" | "context" | "settings" | "about";
180
+ export type ModalTab = "system" | "context" | "permissions" | "settings" | "about";
181
181
 
182
182
  export function getItemTab(item: PillItemType): "system" | "context" {
183
183
  switch (item) {
@@ -448,26 +448,8 @@ function PillItemContent({
448
448
  );
449
449
  }
450
450
 
451
- type SettingsListener = (settings: TopSettings) => void;
452
- const settingsListeners = new Set<SettingsListener>();
453
- let lastNotifiedSettings: TopSettings | null = null;
454
-
455
- export function notifySettingsChanged(settings: TopSettings) {
456
- if (!shouldEmitSnapshotUpdate(
457
- lastNotifiedSettings as unknown as Record<string, unknown> | null,
458
- settings as unknown as Record<string, unknown>,
459
- )) {
460
- return;
461
- }
462
- lastNotifiedSettings = settings;
463
- for (const listener of settingsListeners) {
464
- try {
465
- listener(settings);
466
- } catch {
467
- // Ignore listener errors
468
- }
469
- }
470
- }
451
+ export { notifySettingsChanged } from "./settings-events";
452
+ type SettingsListener = import("./settings-events").SettingsListener;
471
453
 
472
454
  function PillOffline() {
473
455
  const { colors } = usePluginTheme();
@@ -521,9 +503,8 @@ interface PillSegmentProps {
521
503
  }
522
504
 
523
505
  /**
524
- * The composer pill body is host-pressed: legacy hosts wrap `renderPill` in
525
- * their own pressable and route the tap through `resolveDefaultPayload`, so the
526
- * segment must not nest a second interaction handler.
506
+ * The segment must not nest a second interaction handler: on the host-rendered
507
+ * pill it sits inside the host's own pressable.
527
508
  */
528
509
  function PillSegment({
529
510
  item,
@@ -584,7 +565,21 @@ function AllInOnePill({
584
565
  );
585
566
  }
586
567
 
587
- export interface SingleItemPillViewProps extends RenderPillProps<ModalTab> {
568
+ /**
569
+ * Props the pill body views used to receive from the helper's `renderPill`
570
+ * option, which the Paseo 0.9 host shape removed: the host renders the pill
571
+ * body itself, so nothing wires these views any more. The shape is kept
572
+ * because `pill-render.test.tsx` still regression-tests their mobile layout
573
+ * (#507) and that coverage is worth more than the dead wiring.
574
+ */
575
+ interface PillBodyProps extends HostPillProps {
576
+ isOpen: boolean;
577
+ open(payload?: ModalTab): void;
578
+ close(): void;
579
+ toggle(payload?: ModalTab): void;
580
+ }
581
+
582
+ export interface SingleItemPillViewProps extends PillBodyProps {
588
583
  item: PillItemType;
589
584
  defaultTab?: ModalTab;
590
585
  }
@@ -669,7 +664,7 @@ export function SingleItemPillView({
669
664
  );
670
665
  }
671
666
 
672
- function PillView({ isOpen, open, workspaceId, agentId }: RenderPillProps<ModalTab>) {
667
+ function PillView({ isOpen, open, workspaceId, agentId }: PillBodyProps) {
673
668
  // No background settings poll here: the hook re-verifies on mount and
674
669
  // window focus, and mutations invalidate the shared settings cache.
675
670
  // Polling from every mounted pill/modal/surface multiplied daemon reads
@@ -1685,6 +1680,10 @@ function ResourceModal({ theme, workspaceId, agentId, initialTab, payload }: Res
1685
1680
  </>
1686
1681
  )}
1687
1682
 
1683
+ {activeTab === "permissions" && (
1684
+ <PermissionAuditView variant="compact" />
1685
+ )}
1686
+
1688
1687
  {activeTab === "about" && (
1689
1688
  <AboutSection
1690
1689
  name="paseo-top"
@@ -1731,17 +1730,6 @@ function ResourceModal({ theme, workspaceId, agentId, initialTab, payload }: Res
1731
1730
  );
1732
1731
  }
1733
1732
 
1734
- interface LiveCustomPillViewProps {
1735
- pillId: string;
1736
- initial: CustomPillState;
1737
- }
1738
-
1739
- function LiveCustomPillView({ pillId, initial }: LiveCustomPillViewProps) {
1740
- const { data } = useCustomPillsQuery();
1741
- const liveState = data?.pills.find((p) => p.id === pillId) ?? initial;
1742
- return <CustomPillBody state={liveState} />;
1743
- }
1744
-
1745
1733
  interface LiveCustomPillModalProps {
1746
1734
  pillId: string;
1747
1735
  initial: CustomPillStateOutput;
@@ -1856,12 +1844,6 @@ export function contributeClient(client: ComposerPillRegistrar | PluginClientCon
1856
1844
  modalTitle: "Host System Resources",
1857
1845
  modalIcon: "Activity",
1858
1846
  icon: "Cpu",
1859
- resolveDefaultPayload: ({ agentId }) => {
1860
- if (latestSettings.pillMode === "all") {
1861
- return latestSettings.defaultTab;
1862
- }
1863
- return currentCycleTabByAgent.get(agentId) ?? latestSettings.defaultTab;
1864
- },
1865
1847
  resolveLabel: async (ctx) => {
1866
1848
  const snap = await liveSnapshotFor(ctx);
1867
1849
  const items = enabledItemsForSettings(latestSettings);
@@ -1878,7 +1860,6 @@ export function contributeClient(client: ComposerPillRegistrar | PluginClientCon
1878
1860
  };
1879
1861
  },
1880
1862
  refreshIntervalMs: 3000,
1881
- renderPill: (props) => <PillView {...props} />,
1882
1863
  renderModal: (props) => <ResourceModal {...props} />,
1883
1864
  });
1884
1865
  activePills.set("paseo-top", cleanup);
@@ -2083,16 +2064,8 @@ export function contributeClient(client: ComposerPillRegistrar | PluginClientCon
2083
2064
  modalTitle: pillDef.modalTitle,
2084
2065
  icon: pillDef.icon,
2085
2066
  modalIcon: pillDef.icon,
2086
- resolveDefaultPayload: () => pillDef.defaultTab,
2087
2067
  resolveLabel: singleItemLabelResolver(pillDef.item),
2088
2068
  refreshIntervalMs: 5000,
2089
- renderPill: (props) => (
2090
- <SingleItemPillView
2091
- item={pillDef.item}
2092
- defaultTab={pillDef.defaultTab}
2093
- {...props}
2094
- />
2095
- ),
2096
2069
  renderModal: (props) => (
2097
2070
  <ResourceModal initialTab={pillDef.defaultTab} {...props} />
2098
2071
  ),
@@ -2104,7 +2077,7 @@ export function contributeClient(client: ComposerPillRegistrar | PluginClientCon
2104
2077
  }
2105
2078
 
2106
2079
  // Register settings listener
2107
- settingsListeners.add(syncPills);
2080
+ addSettingsListener(syncPills);
2108
2081
 
2109
2082
  // Initial sync synchronously
2110
2083
  syncPills(topSettingsContract.defaultSettings);
@@ -2185,7 +2158,6 @@ export function contributeClient(client: ComposerPillRegistrar | PluginClientCon
2185
2158
  );
2186
2159
  },
2187
2160
  refreshIntervalMs: 5000,
2188
- renderPill: () => <LiveCustomPillView pillId={pill.id} initial={pill} />,
2189
2161
  renderModal: () => (
2190
2162
  <LiveCustomPillModal pillId={pill.id} initial={pill} />
2191
2163
  ),
@@ -2206,7 +2178,7 @@ export function contributeClient(client: ComposerPillRegistrar | PluginClientCon
2206
2178
  const onSettingsChanged = () => {
2207
2179
  void syncCustomPills();
2208
2180
  };
2209
- settingsListeners.add(onSettingsChanged);
2181
+ addSettingsListener(onSettingsChanged);
2210
2182
 
2211
2183
  let disposed = false;
2212
2184
  const cleanup = () => {
@@ -2218,8 +2190,8 @@ export function contributeClient(client: ComposerPillRegistrar | PluginClientCon
2218
2190
  cleanupSidebar = null;
2219
2191
  }
2220
2192
  clearInterval(customPillInterval);
2221
- settingsListeners.delete(syncPills);
2222
- settingsListeners.delete(onSettingsChanged);
2193
+ removeSettingsListener(syncPills);
2194
+ removeSettingsListener(onSettingsChanged);
2223
2195
  for (const cleanup of activePills.values()) {
2224
2196
  cleanup();
2225
2197
  }
@@ -2252,6 +2224,11 @@ const styles = {
2252
2224
  overflow: "hidden",
2253
2225
  flexShrink: 1,
2254
2226
  minWidth: 0,
2227
+ // The pill label is one short line; without a height floor a body that
2228
+ // momentarily renders no text (loading, or a blank host field) collapses to
2229
+ // an unreadable skinny line on mobile (xpufx-org/paseo#507). The host
2230
+ // composer pill is 32pt; keep the inner body at least a line tall.
2231
+ minHeight: 20,
2255
2232
  },
2256
2233
  pillText: {
2257
2234
  fontSize: 11,
@@ -2417,6 +2394,8 @@ const styles = {
2417
2394
  overflow: "hidden",
2418
2395
  flexShrink: 1,
2419
2396
  minWidth: 0,
2397
+ // See pillContainer: the combined pill must keep a line-height floor too.
2398
+ minHeight: 20,
2420
2399
  },
2421
2400
  dividerText: {
2422
2401
  fontSize: 10,
@@ -0,0 +1,33 @@
1
+ import { shouldEmitSnapshotUpdate } from "./vendor/paseo-plugin-helper/index";
2
+ import type { TopSettings } from "../shared/resources";
3
+
4
+ export type SettingsListener = (settings: TopSettings) => void;
5
+ const settingsListeners = new Set<SettingsListener>();
6
+ let lastNotifiedSettings: TopSettings | null = null;
7
+
8
+ export function addSettingsListener(listener: SettingsListener): void {
9
+ settingsListeners.add(listener);
10
+ }
11
+
12
+ export function removeSettingsListener(listener: SettingsListener): void {
13
+ settingsListeners.delete(listener);
14
+ }
15
+
16
+ export function notifySettingsChanged(settings: TopSettings): void {
17
+ if (
18
+ !shouldEmitSnapshotUpdate(
19
+ lastNotifiedSettings as unknown as Record<string, unknown> | null,
20
+ settings as unknown as Record<string, unknown>,
21
+ )
22
+ ) {
23
+ return;
24
+ }
25
+ lastNotifiedSettings = settings;
26
+ for (const listener of settingsListeners) {
27
+ try {
28
+ listener(settings);
29
+ } catch {
30
+ // Ignore listener errors
31
+ }
32
+ }
33
+ }
@@ -18,6 +18,7 @@ import {
18
18
  usePluginTheme,
19
19
  } from "./vendor/paseo-plugin-helper/index";
20
20
  import { formatBytes, formatUptime } from "../shared/vendor/paseo-plugin-helper/index";
21
+ import { PermissionAuditView } from "permission-audit/client";
21
22
  import {
22
23
  topSettingsContract,
23
24
  checkboxesFromTarget,
@@ -27,16 +28,17 @@ import {
27
28
  type TopSettings,
28
29
  } from "../shared/resources";
29
30
  import { PLUGIN_VERSION } from "../shared/version";
30
- import { notifySettingsChanged } from "./pill";
31
+ import { notifySettingsChanged } from "./settings-events";
31
32
  import { ChoiceChips } from "./settings-ui";
32
33
  import { useTopResourceQuery } from "./resources-query";
33
34
  import { FleetView } from "./multi-host-view";
34
35
 
35
- type SurfaceTab = "system" | "fleet" | "settings" | "about";
36
+ type SurfaceTab = "system" | "fleet" | "permissions" | "settings" | "about";
36
37
 
37
38
  const TABS = [
38
39
  { id: "system", label: "Activity", shortLabel: "Activity", icon: "Activity" },
39
40
  { id: "fleet", label: "Fleet", shortLabel: "Fleet", icon: "Server" },
41
+ { id: "permissions", label: "Permissions", shortLabel: "Permissions", icon: "ShieldCheck" },
40
42
  { id: "settings", label: "Settings", shortLabel: "Settings", icon: "Sliders" },
41
43
  { id: "about", label: "About", shortLabel: "About", icon: "Info" },
42
44
  ];
@@ -174,6 +176,8 @@ export function TopDashboardSurface(_props: PluginSurfaceProps) {
174
176
 
175
177
  {activeTab === "fleet" && <FleetView />}
176
178
 
179
+ {activeTab === "permissions" && <PermissionAuditView variant="compact" />}
180
+
177
181
  {activeTab === "settings" && (
178
182
  <Stack gap={12}>
179
183
  <Card variant="elevated">
@@ -112,6 +112,9 @@ export function Badge({
112
112
  >
113
113
  {renderIcon()}
114
114
  <Text
115
+ accessibilityLabel={label}
116
+ numberOfLines={1}
117
+ ellipsizeMode="tail"
115
118
  style={[
116
119
  styles.text,
117
120
  {
@@ -128,6 +131,7 @@ export function Badge({
128
131
  text={label}
129
132
  query={highlightQuery}
130
133
  fuzzyFallback={highlightFuzzyFallback}
134
+ numberOfLines={1}
131
135
  />
132
136
  ) : (
133
137
  label
@@ -144,9 +148,17 @@ const styles = StyleSheet.create({
144
148
  alignSelf: "flex-start",
145
149
  borderWidth: 1,
146
150
  gap: 4,
151
+ // A badge carries operator-supplied strings (ids, paths, refs, model names)
152
+ // of unbounded length. Yoga defaults `flexShrink` to 0, so without this the
153
+ // chip insists on its full text width and pushes its row past the viewport
154
+ // instead of truncating.
155
+ flexShrink: 1,
156
+ maxWidth: "100%",
147
157
  },
148
158
  text: {
149
159
  fontWeight: "600",
160
+ // Truncation only engages if the label itself is allowed to compress.
161
+ flexShrink: 1,
150
162
  },
151
163
  dot: {
152
164
  width: 6,
@@ -112,13 +112,25 @@ export function Button({
112
112
 
113
113
  const attentionMode = resolveButtonAttentionMode(attention);
114
114
 
115
+ // Accessibility touch targets are met with `hitSlop`, which expands the
116
+ // tappable area without moving a single neighbouring pixel. They are NOT met
117
+ // by inflating the painted box: a `minHeight` floor forced `size="sm"` to
118
+ // render 40px tall on mobile against a ~26px recipe, because the floor was
119
+ // computed from the touch target rather than from the size (#647).
120
+ //
121
+ // The slop has to be derived from this button's own height, not a fixed 32 —
122
+ // a 32px baseline silently under-serves every smaller recipe, so `sm` got
123
+ // both a too-tall box and too little slop.
124
+ const contentHeight = Math.round(fontSize * 1.2) + py * 2;
125
+ const hitSlopSide = Math.max(0, (touchTargetMin - contentHeight) / 2);
126
+
115
127
  const pressable = (
116
128
  <Pressable
117
129
  onPress={onPress}
118
130
  disabled={disabled || loading}
119
131
  accessibilityRole={accessibilityRole}
120
132
  accessibilityLabel={accessibilityLabel || label}
121
- hitSlop={Math.max(0, (touchTargetMin - 32) / 2)}
133
+ hitSlop={hitSlopSide}
122
134
  style={({ pressed }) => [
123
135
  styles.base,
124
136
  {
@@ -128,7 +140,6 @@ export function Button({
128
140
  borderRadius: radius,
129
141
  paddingVertical: py,
130
142
  paddingHorizontal: px,
131
- minHeight: Math.max(30, touchTargetMin - 4),
132
143
  opacity: disabled ? 0.45 : 1,
133
144
  },
134
145
  style,
@@ -143,6 +154,8 @@ export function Button({
143
154
  {iconPosition === "left" && renderIcon()}
144
155
  {label ? (
145
156
  <Text
157
+ numberOfLines={1}
158
+ ellipsizeMode="tail"
146
159
  style={[
147
160
  styles.text,
148
161
  {
@@ -176,9 +189,16 @@ const styles = StyleSheet.create({
176
189
  alignItems: "center",
177
190
  justifyContent: "center",
178
191
  gap: 6,
192
+ // Labels are caller-supplied and unbounded (issue titles, branch names,
193
+ // sort labels). Yoga defaults `flexShrink` to 0, so an unconstrained
194
+ // button claims its full label width and overflows narrow viewports.
195
+ flexShrink: 1,
196
+ maxWidth: "100%",
179
197
  },
180
198
  text: {
181
199
  fontWeight: "600",
182
200
  textAlign: "center",
201
+ // The label must be allowed to compress for `numberOfLines` to engage.
202
+ flexShrink: 1,
183
203
  },
184
204
  });
@@ -30,6 +30,11 @@ export interface CardHeaderProps {
30
30
  highlightQuery?: string;
31
31
  }
32
32
 
33
+ // TEMPORARY DIAGNOSTIC (#555) — one-shot probe logging the host icon the
34
+ // first CardHeader render resolves, right before it is used; strip once the
35
+ // missing-binding cause is confirmed.
36
+ let cardHeaderIconProbeFired = false;
37
+
33
38
  export function CardHeader({
34
39
  title,
35
40
  subtitle,
@@ -43,6 +48,33 @@ export function CardHeader({
43
48
  highlightQuery,
44
49
  }: CardHeaderProps) {
45
50
  const { Icon } = getClientHost();
51
+ // TEMPORARY DIAGNOSTIC (#555) — strip once the missing-binding cause is
52
+ // confirmed. A probe that throws must never crash the header itself.
53
+ try {
54
+ if (!cardHeaderIconProbeFired) {
55
+ cardHeaderIconProbeFired = true;
56
+ const describe = (value: unknown): string => {
57
+ if (value === undefined) return "undefined";
58
+ if (value === null) return "null";
59
+ if (typeof value === "function") {
60
+ const named = (value as { displayName?: string; name?: string }).displayName
61
+ ?? (value as { name?: string }).name;
62
+ return `function(${named || "(anonymous)"})`;
63
+ }
64
+ if (typeof value === "object") {
65
+ const symbolTag = (value as { $$typeof?: unknown }).$$typeof;
66
+ return `object($$typeof=${String(symbolTag)}, render=${typeof (value as { render?: unknown }).render})`;
67
+ }
68
+ return typeof value;
69
+ };
70
+ console.warn(
71
+ "[2fado] CardHeader probe: icon=", icon ?? "(no icon prop)",
72
+ "resolved host Icon=", `typeofIcon=${typeof Icon}`, describe(Icon),
73
+ );
74
+ }
75
+ } catch (err) {
76
+ console.warn("[2fado] CardHeader probe failed:", err);
77
+ }
46
78
  const { colors, flair, typography, padding, isCompact } = usePluginTheme();
47
79
 
48
80
  return (
@@ -104,7 +104,11 @@ export function DataTable<T>({
104
104
  : styles.alignLeft,
105
105
  ]}
106
106
  >
107
- <Text style={[styles.headerText, { color: colors.foregroundMuted }]}>
107
+ <Text
108
+ numberOfLines={1}
109
+ ellipsizeMode="tail"
110
+ style={[styles.headerText, { color: colors.foregroundMuted }]}
111
+ >
108
112
  {col.header}
109
113
  </Text>
110
114
  </View>
@@ -162,6 +166,11 @@ const styles = StyleSheet.create({
162
166
  },
163
167
  cell: {
164
168
  justifyContent: "center",
169
+ // Yoga defaults `flexShrink` to 0, so without this a cell whose content
170
+ // carries a long string (a title, a path) refuses to give up width and
171
+ // squeezes its sibling columns instead of truncating.
172
+ flexShrink: 1,
173
+ maxWidth: "100%",
165
174
  },
166
175
  alignLeft: {
167
176
  alignItems: "flex-start",
@@ -177,6 +186,7 @@ const styles = StyleSheet.create({
177
186
  fontWeight: "600",
178
187
  textTransform: "uppercase",
179
188
  letterSpacing: 0.5,
189
+ flexShrink: 1,
180
190
  },
181
191
  compactContainer: {
182
192
  gap: 8,
@@ -190,11 +200,17 @@ const styles = StyleSheet.create({
190
200
  flexDirection: "row",
191
201
  justifyContent: "space-between",
192
202
  alignItems: "center",
203
+ gap: 8,
193
204
  },
194
205
  compactHeader: {
195
206
  fontSize: 12,
207
+ flexShrink: 1,
196
208
  },
197
209
  compactValue: {
198
210
  alignItems: "flex-end",
211
+ // The value side carries the actual data, so it takes the compressible
212
+ // slot and the fixed label beside it keeps its intrinsic width.
213
+ flexShrink: 1,
214
+ maxWidth: "100%",
199
215
  },
200
216
  });
@@ -52,7 +52,14 @@ export function InlineButton({
52
52
  ]}
53
53
  >
54
54
  {typeof icon === "string" ? <Icon name={icon} size={13} color={colors.accent} /> : icon}
55
- <Text style={[styles.text, { color: colors.accent }, textStyle]}>{label}</Text>
55
+ <Text
56
+ accessibilityLabel={label}
57
+ numberOfLines={1}
58
+ ellipsizeMode="tail"
59
+ style={[styles.text, { color: colors.accent }, textStyle]}
60
+ >
61
+ {label}
62
+ </Text>
56
63
  </Pressable>
57
64
  );
58
65
  }
@@ -67,9 +74,15 @@ const styles = StyleSheet.create({
67
74
  paddingHorizontal: 3,
68
75
  paddingVertical: 2,
69
76
  borderRadius: 4,
77
+ // Caller-supplied labels are unbounded; without a shrink budget the control
78
+ // claims its full text width and overflows narrow viewports.
79
+ flexShrink: 1,
80
+ maxWidth: "100%",
70
81
  },
71
82
  text: {
72
83
  fontSize: 12,
73
84
  fontWeight: "600",
85
+ // The label must be allowed to compress for `numberOfLines` to engage.
86
+ flexShrink: 1,
74
87
  },
75
88
  });