paseo-bm-plugin 0.0.0-placeholder.0

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 (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/client/agent-tree.ts +308 -0
  4. package/client/answer-state.ts +62 -0
  5. package/client/bead-chips.tsx +147 -0
  6. package/client/beads-header-button.ts +108 -0
  7. package/client/beads-model.ts +581 -0
  8. package/client/beads-screen.tsx +516 -0
  9. package/client/beads-tab.tsx +58 -0
  10. package/client/chat-card.tsx +636 -0
  11. package/client/chat-cards.ts +1038 -0
  12. package/client/dashboard-actions.tsx +255 -0
  13. package/client/dashboard-model.ts +947 -0
  14. package/client/dashboard-view.ts +215 -0
  15. package/client/dashboard.tsx +318 -0
  16. package/client/launch-manager.ts +323 -0
  17. package/client/launcher.tsx +516 -0
  18. package/client/markdown-view.tsx +112 -0
  19. package/client/markdown.ts +145 -0
  20. package/client/settings.tsx +104 -0
  21. package/client/setup-model.ts +552 -0
  22. package/client/setup-screen.tsx +913 -0
  23. package/client/slot.ts +47 -0
  24. package/client/tree.tsx +204 -0
  25. package/client/ui.tsx +262 -0
  26. package/client/waiting-pills-model.ts +156 -0
  27. package/client/waiting-pills.tsx +201 -0
  28. package/index.client.tsx +232 -0
  29. package/index.server.ts +168 -0
  30. package/package.json +35 -0
  31. package/paseo-plugin.json +6 -0
  32. package/roles/manager.md +181 -0
  33. package/roles/reviewer.md +160 -0
  34. package/roles/worker.md +407 -0
  35. package/server/agent-labels.ts +194 -0
  36. package/server/agent-role.ts +102 -0
  37. package/server/answer-marks.ts +120 -0
  38. package/server/bead-actions.ts +88 -0
  39. package/server/bead-work.ts +80 -0
  40. package/server/beads-store.ts +342 -0
  41. package/server/bm-report.ts +433 -0
  42. package/server/chat-peers.ts +65 -0
  43. package/server/chat-rpc.ts +122 -0
  44. package/server/chat-waiting.ts +182 -0
  45. package/server/collector.ts +629 -0
  46. package/server/config-writer.ts +222 -0
  47. package/server/cost.ts +88 -0
  48. package/server/dashboard-rpc.ts +662 -0
  49. package/server/fallback-detect.ts +183 -0
  50. package/server/fallback-handover.ts +365 -0
  51. package/server/fallback-manager.ts +170 -0
  52. package/server/fallback-reviewer.ts +198 -0
  53. package/server/fallback-rpc.ts +306 -0
  54. package/server/fallback-settings.ts +322 -0
  55. package/server/fallback-state.ts +518 -0
  56. package/server/fallback-switch.ts +191 -0
  57. package/server/fallback-wait.ts +188 -0
  58. package/server/format-check.ts +352 -0
  59. package/server/install-home.ts +187 -0
  60. package/server/live-timeline.ts +129 -0
  61. package/server/manager-instructions.ts +9 -0
  62. package/server/manager.ts +647 -0
  63. package/server/model-costs.ts +238 -0
  64. package/server/notice-queue.ts +315 -0
  65. package/server/notices.ts +81 -0
  66. package/server/paseo-cli.ts +115 -0
  67. package/server/provider-id.ts +12 -0
  68. package/server/review-budget.ts +208 -0
  69. package/server/reviewer-instructions.ts +9 -0
  70. package/server/role-choices.ts +161 -0
  71. package/server/role-extras.ts +270 -0
  72. package/server/role-hook.ts +347 -0
  73. package/server/role-mode.ts +397 -0
  74. package/server/role-settings-rpc.ts +325 -0
  75. package/server/roles.ts +96 -0
  76. package/server/settings-notices.ts +112 -0
  77. package/server/setup-rpc.ts +70 -0
  78. package/server/setup-skills.ts +121 -0
  79. package/server/setup-tools.ts +162 -0
  80. package/server/shell.ts +68 -0
  81. package/server/stop-propagation.ts +365 -0
  82. package/server/tools-check.ts +118 -0
  83. package/server/trace-store.ts +1137 -0
  84. package/server/traces.ts +1356 -0
  85. package/server/worker-instructions.ts +9 -0
  86. package/server/workflow-steps.ts +422 -0
  87. package/shared/bead-ids.ts +25 -0
  88. package/shared/bm-fallback.ts +91 -0
  89. package/shared/bm-format.ts +424 -0
  90. package/shared/bm-questions.ts +213 -0
  91. package/shared/bm-report.ts +433 -0
  92. package/shared/contracts.ts +1371 -0
  93. package/shared/fallback-patterns.ts +201 -0
  94. package/shared/fallback.ts +46 -0
  95. package/shared/new-request.ts +20 -0
  96. package/shared/order.ts +22 -0
  97. package/shared/prices.ts +65 -0
  98. package/shared/settings.ts +57 -0
  99. package/shared/sole-worker.ts +20 -0
  100. package/shared/version.ts +6 -0
  101. package/tsconfig.json +16 -0
@@ -0,0 +1,516 @@
1
+ /**
2
+ * "Beads Manager" surface (WP-113): the screen behind the sidebar item and the
3
+ * Command Center item. It only renders; the launch logic, texts and styles live
4
+ * in `launch-manager.ts`, which is tested without a renderer.
5
+ *
6
+ * Client rules: React Native primitives only, every color from theme.colors,
7
+ * no Node builtin imports.
8
+ */
9
+ import type { PluginTheme } from "@getpaseo/plugin";
10
+ import { type PluginSurfaceProps, usePaseo, useRpc } from "@getpaseo/plugin/client";
11
+ import { useQuery } from "@tanstack/react-query";
12
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
13
+ import {
14
+ AccessibilityInfo,
15
+ ActivityIndicator,
16
+ Animated,
17
+ Pressable,
18
+ ScrollView,
19
+ Text,
20
+ View,
21
+ } from "react-native";
22
+ import { BeadsScreen } from "./beads-screen";
23
+ import { SetupScreen } from "./setup-screen";
24
+ import { DashboardPanel } from "./dashboard";
25
+ import { Icon } from "@getpaseo/plugin/client/react-native";
26
+ import {
27
+ SURFACE_HOME_VIEW,
28
+ WORKSPACE_ACTIONS,
29
+ backLabelOf,
30
+ backOf,
31
+ overviewPolling,
32
+ closedWorkspaces,
33
+ dashboardRequests,
34
+ launcherNotices,
35
+ screenTitleOf,
36
+ workspaceStats,
37
+ type DashboardViewName,
38
+ } from "./dashboard-view";
39
+ import { toneColor, type Tone } from "./dashboard-model";
40
+ import { managerEnsureRpc, tracesWorkspacesRpc, workspacesOverviewRpc } from "../shared/contracts";
41
+ import { PLUGIN_VERSION } from "../shared/version";
42
+ import {
43
+ errorMessageOf,
44
+ launcherStatusLines,
45
+ launcherStyles,
46
+ launchRequests,
47
+ managerLauncher,
48
+ runPendingRequest,
49
+ type StatusLine,
50
+ } from "./launch-manager";
51
+
52
+ interface WorkspaceRow {
53
+ id: string;
54
+ label: string;
55
+ detail: string;
56
+ /** Title of the Metric and Beads screens: the workspace and its project. */
57
+ screenTitle: string;
58
+ }
59
+
60
+ /** One breath of the pulse: slow enough to read as "alive", not as an alarm. */
61
+ const PULSE_MS = 900;
62
+ /** The floor of the pulse, and the resting opacity of a dot with nothing to say. */
63
+ const DIM_OPACITY = 0.3;
64
+ /** Roles in the order they appear in the label, with the word the user reads. */
65
+ const DOT_ROLES = [
66
+ ["manager", "Manager"],
67
+ ["worker", "Worker"],
68
+ ["reviewer", "Reviewer"],
69
+ ] as const;
70
+
71
+ /** Running bm agents of one workspace, per role (`workspaces.overview`). */
72
+ export interface RunningAgentCounts {
73
+ manager: number;
74
+ worker: number;
75
+ reviewer: number;
76
+ }
77
+
78
+ /** Everything the dot beside a project name shows; see `runningDotState`. */
79
+ export interface RunningDotState {
80
+ total: number;
81
+ /** True only when the dot pulses: an idle row and reduced motion both say false. */
82
+ animate: boolean;
83
+ /** Resting opacity, and the value the pulse returns to. */
84
+ opacity: number;
85
+ /** "1 Worker, 1 Reviewer" — read aloud even when the text is not drawn. */
86
+ label: string;
87
+ tone: Tone;
88
+ }
89
+
90
+ /**
91
+ * The dot that says a bm agent is working in this workspace right now
92
+ * (delta 20260917e §4.2): it pulses while any of the three roles runs, and
93
+ * stands still and dim when none does.
94
+ */
95
+ export function runningDotState(
96
+ counts: RunningAgentCounts | undefined,
97
+ reduceMotion: boolean,
98
+ ): RunningDotState | null {
99
+ // The overview has not answered for this row yet. Unknown is not idle, and a
100
+ // dim dot would claim "nothing is running" before anything was counted.
101
+ if (counts === undefined) return null;
102
+ const total = counts.manager + counts.worker + counts.reviewer;
103
+ if (total === 0) {
104
+ return { total, animate: false, opacity: DIM_OPACITY, label: "No Beads agent running", tone: "muted" };
105
+ }
106
+ return {
107
+ total,
108
+ // Reduced motion keeps the signal and drops the motion: the dot goes solid
109
+ // and no loop is started at all.
110
+ animate: !reduceMotion,
111
+ opacity: 1,
112
+ label: DOT_ROLES.filter(([key]) => counts[key] > 0)
113
+ .map(([key, name]) => `${counts[key]} ${name}${counts[key] === 1 ? "" : "s"}`)
114
+ .join(", "),
115
+ tone: "success",
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Puts a state on an `Animated.Value`: the resting opacity always, the pulse
121
+ * only when the state asks for one. Returns the stop function, or `undefined`
122
+ * when there is nothing running to stop — that is the whole reduced-motion
123
+ * promise, so it is a function the test can call rather than a rendered effect.
124
+ */
125
+ export function applyDotPulse(value: Animated.Value, state: RunningDotState): (() => void) | undefined {
126
+ // Set on every change so a dot that stops pulsing lands on its resting
127
+ // opacity instead of wherever the interrupted loop left it.
128
+ value.setValue(state.opacity);
129
+ if (!state.animate) return undefined;
130
+ const loop = Animated.loop(
131
+ Animated.sequence([
132
+ // `useNativeDriver` must be false on react-native-web, which is what
133
+ // Paseo's renderer is (delta 20260917e P3).
134
+ Animated.timing(value, { toValue: DIM_OPACITY, duration: PULSE_MS, useNativeDriver: false }),
135
+ Animated.timing(value, { toValue: 1, duration: PULSE_MS, useNativeDriver: false }),
136
+ ]),
137
+ );
138
+ loop.start();
139
+ return () => loop.stop();
140
+ }
141
+
142
+ /**
143
+ * The operating system's "reduce motion" preference, false when the host cannot
144
+ * answer. A host that fails the query has not asked for less motion; reading
145
+ * that absence as a preference would quietly kill the pulse everywhere.
146
+ */
147
+ export async function readReduceMotion(): Promise<boolean> {
148
+ try {
149
+ return await AccessibilityInfo.isReduceMotionEnabled();
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ function useReduceMotion(): boolean {
156
+ const [reduceMotion, setReduceMotion] = useState(false);
157
+ useEffect(() => {
158
+ let live = true;
159
+ void readReduceMotion().then((value) => {
160
+ if (live) setReduceMotion(value);
161
+ });
162
+ return () => {
163
+ live = false;
164
+ };
165
+ }, []);
166
+ return reduceMotion;
167
+ }
168
+
169
+ function RunningDot(props: {
170
+ counts: RunningAgentCounts | undefined;
171
+ theme: PluginTheme;
172
+ styles: ReturnType<typeof launcherStyles>;
173
+ }) {
174
+ const { counts, theme, styles } = props;
175
+ const reduceMotion = useReduceMotion();
176
+ const state = runningDotState(counts, reduceMotion);
177
+ const opacity = useRef(new Animated.Value(DIM_OPACITY)).current;
178
+ // Restarting on `animate` and `opacity` alone keeps the loop through a poll
179
+ // that only changed the counts, so the dot does not blink back to full.
180
+ useEffect(() => (state === null ? undefined : applyDotPulse(opacity, state)), [
181
+ opacity,
182
+ state?.animate,
183
+ state?.opacity,
184
+ ]);
185
+ if (state === null) return null;
186
+ return (
187
+ <View style={styles.stat} accessibilityLabel={state.label}>
188
+ <Animated.View
189
+ style={{ width: 8, height: 8, borderRadius: 4, opacity, backgroundColor: toneColor(theme, state.tone) }}
190
+ />
191
+ {/* An idle row says it with the dim dot alone; spelling out "nothing is
192
+ running" on every quiet project is noise. The accessibility label
193
+ above carries the words in both states. */}
194
+ {state.total === 0 ? null : (
195
+ <Text style={[styles.statText, { color: toneColor(theme, state.tone) }]} numberOfLines={1}>
196
+ {state.label}
197
+ </Text>
198
+ )}
199
+ </View>
200
+ );
201
+ }
202
+
203
+ /**
204
+ * The surface's status strip: a slash command's notice (tap to dismiss), a host
205
+ * too old to open agents, and the state of the last Manager launch. Built once
206
+ * by the surface and drawn on both the main screen (Setup) and the workspace
207
+ * list (delta 20260918e §4.2); the lines come from `launcherStatusLines`.
208
+ */
209
+ function LauncherStatus({
210
+ lines,
211
+ onDismiss,
212
+ theme,
213
+ styles,
214
+ }: {
215
+ lines: readonly StatusLine[];
216
+ onDismiss: () => void;
217
+ theme: PluginTheme;
218
+ styles: ReturnType<typeof launcherStyles>;
219
+ }) {
220
+ return (
221
+ <>
222
+ {lines.map((line) =>
223
+ line.dismissable ? (
224
+ <Pressable
225
+ key={line.key}
226
+ accessibilityRole="button"
227
+ accessibilityLabel={`${line.text}. Dismiss.`}
228
+ onPress={onDismiss}
229
+ style={styles.row}
230
+ >
231
+ <Text style={styles.rowSubtitle}>{line.text}</Text>
232
+ </Pressable>
233
+ ) : (
234
+ <Text
235
+ key={line.key}
236
+ accessibilityLiveRegion="polite"
237
+ style={[styles.notice, { color: toneColor(theme, line.tone) }]}
238
+ >
239
+ {line.text}
240
+ </Text>
241
+ ),
242
+ )}
243
+ </>
244
+ );
245
+ }
246
+
247
+ export function ManagerLauncherSurface(props: PluginSurfaceProps) {
248
+ const { theme, layout, navigation } = props;
249
+ const paseo = usePaseo();
250
+ // The Dashboard shares this surface (Q-036), so the view is state here and the
251
+ // Command Center hand-off is a queue, exactly like the launch request below.
252
+ // It opens on Setup, the main screen (delta 20260918e §4.2).
253
+ const [view, setView] = useState<DashboardViewName>(SURFACE_HOME_VIEW);
254
+ const [dashboardWorkspace, setDashboardWorkspace] = useState<{ id: string; label: string } | null>(null);
255
+ const ensure = useRpc(managerEnsureRpc);
256
+ const listStored = useRpc(tracesWorkspacesRpc);
257
+ const stored = useQuery({
258
+ queryKey: ["paseo-bm", "launcher", "stored-workspaces"],
259
+ queryFn: () => listStored({}),
260
+ });
261
+ const listOverview = useRpc(workspacesOverviewRpc);
262
+ // Bead counts and running Workers; read and refreshed only while the
263
+ // workspace list shows them (delta 20260918f F5).
264
+ const overview = useQuery({
265
+ queryKey: ["paseo-bm", "launcher", "overview"],
266
+ queryFn: () => listOverview({}),
267
+ ...overviewPolling(view),
268
+ });
269
+ const overviewById = useMemo(
270
+ () => new Map((overview.data?.workspaces ?? []).map((entry) => [entry.workspaceId, entry])),
271
+ [overview.data],
272
+ );
273
+ const openAgent = navigation?.openAgent;
274
+ const state = useSyncExternalStore(
275
+ managerLauncher.subscribe,
276
+ managerLauncher.getState,
277
+ managerLauncher.getState,
278
+ );
279
+ // What a slash command had to say. It has no channel of its own except an
280
+ // error toast, which would paint a success red (dashboard-view.ts).
281
+ const commandNotice = useSyncExternalStore(
282
+ launcherNotices.subscribe,
283
+ launcherNotices.peek,
284
+ launcherNotices.peek,
285
+ );
286
+ const styles = useMemo(() => launcherStyles(theme, layout.compact), [theme, layout.compact]);
287
+
288
+ const workspaces = useQuery({
289
+ queryKey: ["paseo-bm", "launcher", "workspaces"],
290
+ queryFn: async (): Promise<WorkspaceRow[]> => {
291
+ const result = await paseo.workspaces.list({
292
+ sort: [{ key: "activity_at", direction: "desc" }],
293
+ });
294
+ return result.entries
295
+ .filter((workspace) => !workspace.archivingAt)
296
+ .map((workspace) => ({
297
+ id: workspace.id,
298
+ label: workspace.title || workspace.name,
299
+ detail: workspace.projectDisplayName,
300
+ screenTitle: screenTitleOf(workspace.title || workspace.name, workspace.projectDisplayName),
301
+ }));
302
+ },
303
+ });
304
+
305
+ // Open the Dashboard when the Command Center queued one.
306
+ useEffect(() => {
307
+ const run = () => {
308
+ const workspaceId = dashboardRequests.take();
309
+ if (workspaceId === null) return;
310
+ const known = workspaces.data?.find((workspace) => workspace.id === workspaceId);
311
+ setDashboardWorkspace({ id: workspaceId, label: known?.screenTitle ?? workspaceId });
312
+ setView("dashboard");
313
+ };
314
+ run();
315
+ return dashboardRequests.subscribe(run);
316
+ }, [workspaces.data]);
317
+
318
+ // Run a launch queued by the Command Center item, now, whenever one arrives,
319
+ // and when a pending launch ends (a request that came meanwhile waited).
320
+ useEffect(() => {
321
+ const run = () => {
322
+ void runPendingRequest(launchRequests, managerLauncher, { ensure, openAgent });
323
+ };
324
+ run();
325
+ const stopRequests = launchRequests.subscribe(run);
326
+ const stopLauncher = managerLauncher.subscribe(run);
327
+ return () => {
328
+ stopRequests();
329
+ stopLauncher();
330
+ };
331
+ }, [ensure, openAgent]);
332
+
333
+ const pending = state.status === "pending";
334
+ // Every ← goes where `backOf` says: Metric and Beads to the workspace list,
335
+ // the list to Setup. Setup is the main screen and has no ←.
336
+ const goBack = () => setView(backOf(view) ?? SURFACE_HOME_VIEW);
337
+
338
+ // Built ONCE and placed on every view of the surface (Setup, the workspace
339
+ // list, Metric, Beads), so a slash command's notice and a launch error are
340
+ // seen wherever the surface is.
341
+ const status = (
342
+ <LauncherStatus
343
+ lines={launcherStatusLines({ commandNotice, canOpenAgents: openAgent !== undefined, state })}
344
+ onDismiss={() => launcherNotices.take()}
345
+ theme={theme}
346
+ styles={styles}
347
+ />
348
+ );
349
+
350
+ if (view === "setup") {
351
+ return <SetupScreen {...props} onOpenWorkspaces={() => setView("workspaces")} status={status} />;
352
+ }
353
+
354
+ if (view === "beads" && dashboardWorkspace !== null) {
355
+ return (
356
+ <BeadsScreen
357
+ {...props}
358
+ workspaceId={dashboardWorkspace.id}
359
+ workspaceLabel={dashboardWorkspace.label}
360
+ onBack={goBack}
361
+ backLabel={backLabelOf(view) ?? undefined}
362
+ status={status}
363
+ />
364
+ );
365
+ }
366
+
367
+ if (view === "dashboard" && dashboardWorkspace !== null) {
368
+ return (
369
+ <DashboardPanel
370
+ {...props}
371
+ workspaceId={dashboardWorkspace.id}
372
+ workspaceLabel={dashboardWorkspace.label}
373
+ onBack={goBack}
374
+ backLabel={backLabelOf(view) ?? undefined}
375
+ status={status}
376
+ />
377
+ );
378
+ }
379
+
380
+ return (
381
+ <ScrollView style={styles.screen} contentContainerStyle={styles.content}>
382
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
383
+ <Pressable
384
+ accessibilityRole="button"
385
+ accessibilityLabel={backLabelOf(view) ?? undefined}
386
+ onPress={goBack}
387
+ style={styles.iconButton}
388
+ >
389
+ <Text style={styles.rowTitle}>←</Text>
390
+ </Pressable>
391
+ <Text style={[styles.title, { flex: 1 }]}>Workspaces</Text>
392
+ </View>
393
+ <Text style={styles.body}>Chat with a workspace's Beads Manager, or open its metrics or beads.</Text>
394
+
395
+ {status}
396
+
397
+ {workspaces.isPending ? <ActivityIndicator color={styles.spinner.color} /> : null}
398
+ {workspaces.isError ? (
399
+ <Text style={[styles.notice, { color: toneColor(theme, "danger") }]}>
400
+ {`Could not load workspaces. ${errorMessageOf(workspaces.error)}`}
401
+ </Text>
402
+ ) : null}
403
+ {workspaces.data?.length === 0 ? (
404
+ <Text style={styles.body}>No workspaces on this host yet.</Text>
405
+ ) : null}
406
+
407
+ {/* The rows render even without `navigation.openAgent`: only the Manager
408
+ button needs it, while the Dashboard is read-only and must stay
409
+ reachable on an older host (REQ-040d). They keep the order
410
+ `workspaces.list` returns: most recent activity first, no pinning
411
+ (delta 20260918f §4.5). */}
412
+ {(workspaces.data ?? []).map((workspace) => {
413
+ const busyHere = pending && state.workspaceId === workspace.id;
414
+ const open = (view: "dashboard" | "beads") => {
415
+ setDashboardWorkspace({ id: workspace.id, label: workspace.screenTitle });
416
+ setView(view);
417
+ };
418
+ const stats = workspaceStats(overviewById.get(workspace.id));
419
+ return (
420
+ <View key={workspace.id} style={styles.row}>
421
+ <View style={{ gap: 4, flexShrink: 1 }}>
422
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
423
+ <Text style={[styles.rowTitle, { flexShrink: 1 }]} numberOfLines={1}>
424
+ {workspace.label}
425
+ </Text>
426
+ <RunningDot counts={overviewById.get(workspace.id)?.runningAgents} theme={theme} styles={styles} />
427
+ </View>
428
+ <Text style={styles.rowSubtitle} numberOfLines={1}>
429
+ {workspace.detail}
430
+ </Text>
431
+ {stats.length === 0 ? null : (
432
+ <View style={styles.stats} accessibilityLabel={stats.map((stat) => stat.label).join(", ")}>
433
+ {stats.map((stat) => (
434
+ <View key={stat.key} style={styles.stat}>
435
+ <Icon name={stat.icon} size={13} color={toneColor(theme, stat.tone)} />
436
+ <Text style={[styles.statText, { color: toneColor(theme, stat.tone) }]}>{stat.value}</Text>
437
+ </View>
438
+ ))}
439
+ </View>
440
+ )}
441
+ </View>
442
+ {/* The rows render even without `navigation.openAgent`: only the
443
+ Manager action needs it, while Metric and Beads are read-only
444
+ and must stay reachable on an older host (REQ-040d). */}
445
+ <View style={styles.actions}>
446
+ {WORKSPACE_ACTIONS.map((action) => {
447
+ if (action.key === "manager" && !openAgent) return null;
448
+ const primary = action.key === "manager";
449
+ const disabled = primary && pending;
450
+ const textStyle = primary ? styles.actionPrimaryText : styles.actionText;
451
+ return (
452
+ <Pressable
453
+ key={action.key}
454
+ accessibilityRole="button"
455
+ accessibilityLabel={
456
+ action.key === "manager"
457
+ ? `Go to the Beads Manager of ${workspace.label}`
458
+ : action.key === "metric"
459
+ ? `Open the Beads metrics for ${workspace.label}`
460
+ : `Open the beads of ${workspace.label}`
461
+ }
462
+ accessibilityState={primary ? { disabled, busy: busyHere } : undefined}
463
+ disabled={disabled}
464
+ onPress={() => {
465
+ if (action.key === "manager") void managerLauncher.launch(workspace.id, { ensure, openAgent: openAgent! });
466
+ else open(action.key === "metric" ? "dashboard" : "beads");
467
+ }}
468
+ style={[styles.action, primary ? styles.actionPrimary : null, disabled ? styles.buttonDisabled : null]}
469
+ >
470
+ {primary && busyHere ? (
471
+ <ActivityIndicator color={textStyle.color} />
472
+ ) : (
473
+ <>
474
+ <Icon name={action.icon} size={14} color={textStyle.color} />
475
+ <Text style={textStyle}>{action.label}</Text>
476
+ </>
477
+ )}
478
+ </Pressable>
479
+ );
480
+ })}
481
+ </View>
482
+ </View>
483
+ );
484
+ })}
485
+
486
+ {/* History of workspaces Paseo no longer lists: still readable, and
487
+ movable onto the reopened workspace from its Metric screen. */}
488
+ {closedWorkspaces(stored.data?.workspaces ?? [], workspaces.data?.map((workspace) => workspace.id) ?? []).map(
489
+ (closed, index) => (
490
+ <View key={closed.workspaceId} style={{ gap: 6 }}>
491
+ {index === 0 ? <Text style={styles.title}>Closed workspaces with history</Text> : null}
492
+ <View style={styles.row}>
493
+ <View style={{ flex: 1 }}>
494
+ <Text style={styles.rowTitle}>{closed.label}</Text>
495
+ <Text style={styles.rowSubtitle}>{closed.detail}</Text>
496
+ </View>
497
+ <Pressable
498
+ accessibilityRole="button"
499
+ accessibilityLabel={`Open the Beads metrics of the closed workspace ${closed.label}`}
500
+ onPress={() => {
501
+ setDashboardWorkspace({ id: closed.workspaceId, label: closed.label });
502
+ setView("dashboard");
503
+ }}
504
+ style={styles.button}
505
+ >
506
+ <Text style={styles.buttonText}>Metric</Text>
507
+ </Pressable>
508
+ </View>
509
+ </View>
510
+ ),
511
+ )}
512
+
513
+ <Text style={styles.footer}>{`paseo-bm ${PLUGIN_VERSION}`}</Text>
514
+ </ScrollView>
515
+ );
516
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Draws the blocks from `markdown.ts` with React Native primitives.
3
+ * Colours come from the theme; sizes from the Dashboard styles.
4
+ */
5
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
6
+ import { Text, View } from "react-native";
7
+ import { parseMarkdown, type Inline, type MarkdownBlock } from "./markdown";
8
+
9
+ type Theme = PluginSurfaceProps["theme"];
10
+
11
+ const MONO = "Menlo";
12
+
13
+ function Spans({ spans, theme, size, weight }: { spans: Inline[]; theme: Theme; size: number; weight?: "600" | "700" }) {
14
+ return (
15
+ <Text style={{ color: theme.colors.foreground, fontSize: size, lineHeight: size * 1.45, fontWeight: weight }} selectable>
16
+ {spans.map((span, index) => (
17
+ <Text
18
+ key={index}
19
+ style={
20
+ span.code
21
+ ? { fontFamily: MONO, fontSize: size - 1, backgroundColor: theme.colors.surface2, color: theme.colors.foreground }
22
+ : { fontWeight: span.bold ? "700" : weight, fontStyle: span.italic ? "italic" : "normal" }
23
+ }
24
+ >
25
+ {span.text}
26
+ </Text>
27
+ ))}
28
+ </Text>
29
+ );
30
+ }
31
+
32
+ function Block({ block, theme, size }: { block: MarkdownBlock; theme: Theme; size: number }) {
33
+ switch (block.kind) {
34
+ case "heading":
35
+ return (
36
+ <View style={{ marginTop: block.level <= 2 ? 10 : 6 }}>
37
+ <Spans spans={block.spans} theme={theme} size={size + Math.max(0, 6 - block.level * 1.5)} weight="700" />
38
+ </View>
39
+ );
40
+ case "paragraph":
41
+ return <Spans spans={block.spans} theme={theme} size={size} />;
42
+ case "bullet":
43
+ case "numbered": {
44
+ const marker =
45
+ block.kind === "numbered" ? `${block.number}.` : block.checked === undefined ? "•" : block.checked ? "☑" : "☐";
46
+ return (
47
+ <View style={{ flexDirection: "row", gap: 6, paddingLeft: block.depth * 14 }}>
48
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: size, lineHeight: size * 1.45, minWidth: 14 }}>
49
+ {marker}
50
+ </Text>
51
+ <View style={{ flex: 1 }}>
52
+ <Spans spans={block.spans} theme={theme} size={size} />
53
+ </View>
54
+ </View>
55
+ );
56
+ }
57
+ case "quote":
58
+ return (
59
+ <View style={{ borderLeftWidth: 3, borderLeftColor: theme.colors.border, paddingLeft: 8 }}>
60
+ <Spans spans={block.spans} theme={theme} size={size} />
61
+ </View>
62
+ );
63
+ case "code":
64
+ return (
65
+ <View style={{ backgroundColor: theme.colors.surface2, borderRadius: 6, padding: 8 }}>
66
+ <Text style={{ fontFamily: MONO, fontSize: size - 1, color: theme.colors.foreground }} selectable>
67
+ {block.text}
68
+ </Text>
69
+ </View>
70
+ );
71
+ case "table":
72
+ return (
73
+ <View style={{ borderWidth: 1, borderColor: theme.colors.border, borderRadius: 6 }}>
74
+ {[block.header, ...block.rows].map((row, rowIndex) => (
75
+ <View
76
+ key={rowIndex}
77
+ style={{
78
+ flexDirection: "row",
79
+ borderTopWidth: rowIndex === 0 ? 0 : 1,
80
+ borderTopColor: theme.colors.border,
81
+ backgroundColor: rowIndex === 0 ? theme.colors.surface2 : "transparent",
82
+ }}
83
+ >
84
+ {row.map((cell, cellIndex) => (
85
+ <View key={cellIndex} style={{ flex: 1, padding: 6 }}>
86
+ <Text
87
+ style={{ color: theme.colors.foreground, fontSize: size - 1, fontWeight: rowIndex === 0 ? "600" : "400" }}
88
+ selectable
89
+ >
90
+ {cell}
91
+ </Text>
92
+ </View>
93
+ ))}
94
+ </View>
95
+ ))}
96
+ </View>
97
+ );
98
+ case "rule":
99
+ return <View style={{ height: 1, backgroundColor: theme.colors.border, marginVertical: 4 }} />;
100
+ }
101
+ }
102
+
103
+ export function MarkdownView({ source, theme, compact }: { source: string; theme: Theme; compact: boolean }) {
104
+ const size = compact ? 12 : 14;
105
+ return (
106
+ <View style={{ gap: 6 }}>
107
+ {parseMarkdown(source).map((block, index) => (
108
+ <Block key={index} block={block} theme={theme} size={size} />
109
+ ))}
110
+ </View>
111
+ );
112
+ }