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
+ * The Beads screen (design delta 20260916-beads-screen): five overview
3
+ * sections, a filterable bead list, and a bead detail with three hand-off
4
+ * actions. Every action is confirmed first and goes to the workspace's Beads
5
+ * Manager; this screen never writes the bead store.
6
+ *
7
+ * Client rules: React Native primitives only, colours from `theme.colors`, no
8
+ * Node import, no `server/` import.
9
+ */
10
+ import { type PluginSurfaceProps, useRpc } from "@getpaseo/plugin/client";
11
+ import { Icon } from "@getpaseo/plugin/client/react-native";
12
+ import { useQuery } from "@tanstack/react-query";
13
+ import { useMemo, useState, useSyncExternalStore } from "react";
14
+ import { ActivityIndicator, Pressable, ScrollView, Text, TextInput, View } from "react-native";
15
+ import { beadsActionRpc, beadsGetRpc, beadsListRpc, type BeadAction, type BeadRow } from "../shared/contracts";
16
+ import { MarkdownView } from "./markdown-view";
17
+ import {
18
+ EMPTY_FILTER,
19
+ LABEL_PREVIEW,
20
+ SORT_OPTIONS,
21
+ activeFilters,
22
+ previewValues,
23
+ sortBeads,
24
+ type FacetValue,
25
+ type SortKey,
26
+ actionSpec,
27
+ actionsFor,
28
+ beadsOverview,
29
+ closedBeadsVisibility,
30
+ doneText,
31
+ beadListItems,
32
+ groupBeads,
33
+ facetsOf,
34
+ filterBeads,
35
+ priorityLabel,
36
+ statusBadge,
37
+ toggle,
38
+ workSummary,
39
+ type BeadFilter,
40
+ } from "./beads-model";
41
+ import { dashboardStyles, toneColor, type Badge } from "./dashboard-model";
42
+ import { errorMessageOf } from "./launch-manager";
43
+ import { BarChart, BeadRowCard, Chip, RoleMark, StatCards, WorkspaceScreenHeader, type Styles, type Theme, type WorkspaceScreenProps } from "./ui";
44
+
45
+
46
+ const LIST_LIMIT = 200;
47
+
48
+ const facetText = (value: string) => value.replace("in_progress", "in progress");
49
+
50
+ function FacetRow({
51
+ title,
52
+ values,
53
+ selected,
54
+ onToggle,
55
+ styles,
56
+ theme,
57
+ expanded,
58
+ onExpand,
59
+ }: {
60
+ title: string;
61
+ values: readonly FacetValue[];
62
+ selected: ReadonlySet<string>;
63
+ onToggle: (value: string) => void;
64
+ styles: Styles;
65
+ theme: Theme;
66
+ /** Label groups preview a few values; the others show everything. */
67
+ expanded?: boolean;
68
+ onExpand?: () => void;
69
+ }) {
70
+ if (values.length === 0) return null;
71
+ const { shown, hidden } =
72
+ expanded === undefined ? { shown: values, hidden: 0 } : previewValues(values, selected, expanded);
73
+ return (
74
+ <View style={{ gap: 4 }}>
75
+ <Text style={styles.body}>{title}</Text>
76
+ <View style={styles.chipRow}>
77
+ {shown.map((entry) => (
78
+ <Chip
79
+ key={entry.value}
80
+ badge={{ text: `${facetText(entry.value)} ${entry.count}`, tone: selected.has(entry.value) ? "info" : "muted" }}
81
+ selected={selected.has(entry.value)}
82
+ onPress={() => onToggle(entry.value)}
83
+ styles={styles}
84
+ theme={theme}
85
+ />
86
+ ))}
87
+ {hidden > 0 || (expanded === true && values.length > LABEL_PREVIEW) ? (
88
+ <Pressable accessibilityRole="button" onPress={onExpand}>
89
+ <Text style={[styles.badge, { color: toneColor(theme, "info"), paddingVertical: 2 }]}>
90
+ {hidden > 0 ? `+${hidden} more` : "show less"}
91
+ </Text>
92
+ </Pressable>
93
+ ) : null}
94
+ </View>
95
+ </View>
96
+ );
97
+ }
98
+
99
+ /** A bead's detail and actions; also opened from a chat card or the chat panel. */
100
+ export function BeadDetailPanel({
101
+ workspaceId,
102
+ bead,
103
+ styles,
104
+ theme,
105
+ navigation,
106
+ }: {
107
+ workspaceId: string;
108
+ bead: BeadRow;
109
+ styles: Styles;
110
+ theme: Theme;
111
+ navigation?: PluginSurfaceProps["navigation"];
112
+ }) {
113
+ const getBead = useRpc(beadsGetRpc);
114
+ const runAction = useRpc(beadsActionRpc);
115
+ const [pending, setPending] = useState<BeadAction | null>(null);
116
+ const [busy, setBusy] = useState(false);
117
+ const [result, setResult] = useState<{ text: string; managerId: string | null; tone: Badge["tone"] } | null>(null);
118
+ const detail = useQuery({
119
+ queryKey: ["paseo-bm", "bead", workspaceId, bead.id],
120
+ queryFn: () => getBead({ workspaceId, id: bead.id }),
121
+ });
122
+
123
+ const confirm = async (action: BeadAction) => {
124
+ setBusy(true);
125
+ try {
126
+ const sent = await runAction({ workspaceId, id: bead.id, action });
127
+ setResult({
128
+ text: `Sent to the Beads Manager${sent.created ? " (created for this workspace)" : ""}. It will hand the bead to a Worker.`,
129
+ managerId: sent.managerId,
130
+ tone: "success",
131
+ });
132
+ } catch (failure) {
133
+ setResult({ text: errorMessageOf(failure), managerId: null, tone: "danger" });
134
+ } finally {
135
+ setBusy(false);
136
+ setPending(null);
137
+ }
138
+ };
139
+
140
+ const full = detail.data?.bead;
141
+ const work = workSummary(bead, new Date());
142
+ const spec = pending === null ? null : actionSpec(pending, bead);
143
+ return (
144
+ <View style={{ gap: 6, paddingTop: 6 }}>
145
+ {detail.isPending ? <ActivityIndicator color={styles.spinner.color} /> : null}
146
+ {detail.isError ? (
147
+ <Text style={[styles.body, { color: toneColor(theme, "danger") }]}>{errorMessageOf(detail.error)}</Text>
148
+ ) : null}
149
+ {full === undefined ? null : (
150
+ <>
151
+ <View style={styles.chipRow}>
152
+ {full.labels.map((label) => (
153
+ <Chip key={label} badge={{ text: label, tone: "info" }} styles={styles} theme={theme} />
154
+ ))}
155
+ </View>
156
+ <Text style={styles.body}>
157
+ {[
158
+ full.createdAt === null ? null : `created ${full.createdAt.slice(0, 16).replace("T", " ")}`,
159
+ full.updatedAt === null ? null : `updated ${full.updatedAt.slice(0, 16).replace("T", " ")}`,
160
+ full.closedAt === null ? null : `closed ${full.closedAt.slice(0, 16).replace("T", " ")}`,
161
+ full.parentId === null ? null : `parent ${full.parentId}`,
162
+ full.blockedBy.length === 0 ? null : `blocked by ${full.blockedBy.join(", ")}`,
163
+ full.children.length === 0 ? null : `${full.children.length} child bead(s)`,
164
+ ]
165
+ .filter((part) => part !== null)
166
+ .join(" · ")}
167
+ </Text>
168
+ {work === null ? null : (
169
+ <View style={[styles.card, { gap: 4 }]}>
170
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
171
+ <RoleMark kind="worker" theme={theme} />
172
+ <Text style={styles.sectionTitle}>Being worked on</Text>
173
+ </View>
174
+ {work.lines.map((line) => (
175
+ <Text key={line} style={styles.body}>
176
+ {line}
177
+ </Text>
178
+ ))}
179
+ {work.agentId !== null && navigation?.openAgent !== undefined ? (
180
+ <Pressable
181
+ accessibilityRole="button"
182
+ onPress={() => navigation.openAgent({ agentId: work.agentId! })}
183
+ style={[styles.secondaryButton, { alignSelf: "flex-start" }]}
184
+ >
185
+ <Text style={styles.secondaryButtonText}>Open the Worker</Text>
186
+ </Pressable>
187
+ ) : null}
188
+ </View>
189
+ )}
190
+ {full.closeReason === null ? null : <Text style={styles.body} selectable>{`Close reason: ${full.closeReason}`}</Text>}
191
+ {full.description === null ? (
192
+ <Text style={styles.body}>(no description)</Text>
193
+ ) : (
194
+ <View style={[styles.card, { backgroundColor: theme.colors.surface0 }]}>
195
+ <MarkdownView source={full.description} theme={theme} compact={false} />
196
+ </View>
197
+ )}
198
+ </>
199
+ )}
200
+
201
+ {result === null ? null : (
202
+ <View style={{ gap: 4 }}>
203
+ <Text style={[styles.body, { color: toneColor(theme, result.tone) }]}>{result.text}</Text>
204
+ {result.managerId !== null && navigation?.openAgent !== undefined ? (
205
+ <Pressable
206
+ accessibilityRole="button"
207
+ onPress={() => navigation.openAgent({ agentId: result.managerId! })}
208
+ style={[styles.secondaryButton, { alignSelf: "flex-start" }]}
209
+ >
210
+ <Text style={styles.secondaryButtonText}>Open the Beads Manager</Text>
211
+ </Pressable>
212
+ ) : null}
213
+ </View>
214
+ )}
215
+
216
+ {spec === null ? (
217
+ <View style={styles.chipRow}>
218
+ {actionsFor(bead).map((action) => {
219
+ const label = actionSpec(action, bead);
220
+ return (
221
+ <Pressable
222
+ key={action}
223
+ accessibilityRole="button"
224
+ disabled={busy}
225
+ onPress={() => {
226
+ setResult(null);
227
+ setPending(action);
228
+ }}
229
+ style={label.danger ? styles.dangerButton : styles.button}
230
+ >
231
+ <Text style={label.danger ? styles.dangerButtonText : styles.buttonText}>{label.label}</Text>
232
+ </Pressable>
233
+ );
234
+ })}
235
+ </View>
236
+ ) : (
237
+ <View style={styles.card}>
238
+ <Text style={styles.sectionTitle}>{spec.title}</Text>
239
+ <Text style={styles.body}>{spec.body}</Text>
240
+ <View style={styles.chipRow}>
241
+ {/* Cancel first: the safe choice is the one under the thumb. */}
242
+ <Pressable accessibilityRole="button" onPress={() => setPending(null)} style={styles.secondaryButton}>
243
+ <Text style={styles.secondaryButtonText}>No, cancel</Text>
244
+ </Pressable>
245
+ <Pressable
246
+ accessibilityRole="button"
247
+ disabled={busy}
248
+ onPress={() => {
249
+ void confirm(pending!);
250
+ }}
251
+ style={spec.danger ? styles.dangerButton : styles.button}
252
+ >
253
+ <Text style={spec.danger ? styles.dangerButtonText : styles.buttonText}>
254
+ {busy ? "Sending…" : spec.confirmLabel}
255
+ </Text>
256
+ </Pressable>
257
+ </View>
258
+ </View>
259
+ )}
260
+ </View>
261
+ );
262
+ }
263
+
264
+ export function BeadsScreen({ theme, layout, navigation, workspaceId, workspaceLabel, onBack, backLabel, status }: WorkspaceScreenProps) {
265
+ const styles = useMemo(() => dashboardStyles(theme, layout.compact), [theme, layout.compact]);
266
+ const listBeads = useRpc(beadsListRpc);
267
+ const [filter, setFilter] = useState<BeadFilter>(EMPTY_FILTER);
268
+ const [openId, setOpenId] = useState<string | null>(null);
269
+ const [sortKey, setSortKey] = useState<SortKey>("updated");
270
+ const [descending, setDescending] = useState(true);
271
+ const [showLabels, setShowLabels] = useState(false);
272
+ const [expandedGroups, setExpandedGroups] = useState<ReadonlySet<string>>(new Set());
273
+ const beads = useQuery({
274
+ queryKey: ["paseo-bm", "beads-list", workspaceId],
275
+ queryFn: () => listBeads({ workspaceId }),
276
+ });
277
+
278
+ const rows = beads.data?.beads ?? [];
279
+ const facets = useMemo(() => facetsOf(rows, filter), [rows, filter]);
280
+ const shown = useMemo(() => sortBeads(filterBeads(rows, filter), sortKey, descending), [rows, filter, sortKey, descending]);
281
+ // Closed beads are hidden until asked for, for the rest of the app session (Q9).
282
+ const showClosed = useSyncExternalStore(closedBeadsVisibility.subscribe, closedBeadsVisibility.get, closedBeadsVisibility.get);
283
+ const grouped = useMemo(() => groupBeads(shown, { showClosed, limit: LIST_LIMIT }), [shown, showClosed]);
284
+ const active = activeFilters(filter);
285
+ const overview = beads.data === undefined ? null : beadsOverview(rows, beads.data.stats, new Date());
286
+ // Done / total at a glance, counted like the Progress card (Q11).
287
+ const done = overview === null ? null : doneText(overview.progress);
288
+ const now = new Date();
289
+
290
+ return (
291
+ <ScrollView style={styles.screen} contentContainerStyle={styles.content}>
292
+ <WorkspaceScreenHeader
293
+ title={workspaceLabel === undefined ? null : `Beads · ${workspaceLabel}`}
294
+ onBack={onBack}
295
+ backLabel={backLabel ?? "Back"}
296
+ status={status}
297
+ styles={styles}
298
+ right={
299
+ <>
300
+ {done === null ? null : (
301
+ <Text style={styles.body} accessibilityLabel={done.label}>
302
+ {done.text}
303
+ </Text>
304
+ )}
305
+ <Pressable accessibilityRole="button" onPress={() => void beads.refetch()} style={styles.secondaryButton}>
306
+ <Text style={styles.secondaryButtonText}>Refresh</Text>
307
+ </Pressable>
308
+ </>
309
+ }
310
+ />
311
+
312
+ {beads.data === undefined ? null : (
313
+ <Text style={styles.body} selectable>{`Read from ${beads.data.stats.source}`}</Text>
314
+ )}
315
+ {beads.isPending ? <ActivityIndicator color={styles.spinner.color} /> : null}
316
+ {beads.isError ? (
317
+ <Text style={[styles.body, { color: toneColor(theme, "danger") }]}>{errorMessageOf(beads.error)}</Text>
318
+ ) : null}
319
+
320
+ {overview === null ? null : (
321
+ <>
322
+ {/* 1. Status */}
323
+ <StatCards cards={overview.status} styles={styles} />
324
+
325
+ {/* 2. Progress */}
326
+ <View style={styles.card}>
327
+ <Text style={styles.sectionTitle}>Progress</Text>
328
+ <View style={styles.barTrack}>
329
+ <View style={[styles.barFill, { width: `${Math.round(overview.progress.share * 100)}%` }]} />
330
+ </View>
331
+ <Text style={styles.body}>{overview.progress.label}</Text>
332
+ </View>
333
+
334
+ <View style={styles.cards}>
335
+ <BarChart title="By type" bars={overview.byType} styles={styles} labelWidth={70} />
336
+ <BarChart title="By priority" bars={overview.byPriority} styles={styles} labelWidth={70} />
337
+ </View>
338
+
339
+ {/* 5. Time */}
340
+ <StatCards cards={overview.timing} styles={styles} />
341
+ </>
342
+ )}
343
+
344
+ {/* Filters */}
345
+ <View style={[styles.card, { gap: 8 }]}>
346
+ <TextInput
347
+ value={filter.text}
348
+ onChangeText={(text) => setFilter({ ...filter, text })}
349
+ placeholder="Search id or title"
350
+ placeholderTextColor={theme.colors.foregroundMuted}
351
+ style={[styles.body, { borderWidth: 1, borderColor: theme.colors.border, borderRadius: 8, padding: 8 }]}
352
+ />
353
+ {active.length > 0 ? (
354
+ <View style={styles.chipRow}>
355
+ {active.map((entry) => (
356
+ <Chip
357
+ key={`${entry.facet}:${entry.value}`}
358
+ badge={{ text: `${facetText(entry.value)} ✕`, tone: "info" }}
359
+ selected
360
+ onPress={() => setFilter({ ...filter, [entry.facet]: toggle(filter[entry.facet], entry.value) })}
361
+ styles={styles}
362
+ theme={theme}
363
+ />
364
+ ))}
365
+ <Pressable accessibilityRole="button" onPress={() => setFilter(EMPTY_FILTER)}>
366
+ <Text style={[styles.badge, { color: toneColor(theme, "danger"), paddingVertical: 2 }]}>Clear all</Text>
367
+ </Pressable>
368
+ </View>
369
+ ) : null}
370
+ <FacetRow
371
+ title="Status"
372
+ values={facets.statuses}
373
+ selected={filter.statuses}
374
+ onToggle={(value) => setFilter({ ...filter, statuses: toggle(filter.statuses, value) })}
375
+ styles={styles}
376
+ theme={theme}
377
+ />
378
+ <FacetRow
379
+ title="Type"
380
+ values={facets.types}
381
+ selected={filter.types}
382
+ onToggle={(value) => setFilter({ ...filter, types: toggle(filter.types, value) })}
383
+ styles={styles}
384
+ theme={theme}
385
+ />
386
+ <FacetRow
387
+ title="Priority"
388
+ values={facets.priorities}
389
+ selected={filter.priorities}
390
+ onToggle={(value) => setFilter({ ...filter, priorities: toggle(filter.priorities, value) })}
391
+ styles={styles}
392
+ theme={theme}
393
+ />
394
+ {facets.labelGroups.length === 0 ? null : (
395
+ <Pressable accessibilityRole="button" accessibilityState={{ expanded: showLabels }} onPress={() => setShowLabels(!showLabels)}>
396
+ <Text style={styles.body}>
397
+ {`${showLabels ? "▾" : "▸"} Labels · ${facets.labelGroups.map((group) => `${group.category}${group.selected > 0 ? ` (${group.selected})` : ""}`).join(", ")}`}
398
+ </Text>
399
+ </Pressable>
400
+ )}
401
+ {showLabels
402
+ ? facets.labelGroups.map((group) => (
403
+ <FacetRow
404
+ key={group.category}
405
+ title={group.category}
406
+ values={group.values}
407
+ selected={filter.labels}
408
+ onToggle={(value) => setFilter({ ...filter, labels: toggle(filter.labels, value) })}
409
+ styles={styles}
410
+ theme={theme}
411
+ expanded={expandedGroups.has(group.category)}
412
+ onExpand={() => setExpandedGroups(toggle(expandedGroups, group.category))}
413
+ />
414
+ ))
415
+ : null}
416
+ </View>
417
+
418
+ {/* Sort */}
419
+ <View style={[styles.chipRow, { alignItems: "center" }]}>
420
+ <Text style={styles.body}>Sort</Text>
421
+ {SORT_OPTIONS.map((option) => {
422
+ const on = option.key === sortKey;
423
+ return (
424
+ <Chip
425
+ key={option.key}
426
+ badge={{ text: on ? `${option.label} ${descending ? "↓" : "↑"}` : option.label, tone: on ? "info" : "muted" }}
427
+ selected={on}
428
+ onPress={() => {
429
+ if (on) setDescending(!descending);
430
+ else {
431
+ setSortKey(option.key);
432
+ setDescending(true);
433
+ }
434
+ }}
435
+ styles={styles}
436
+ theme={theme}
437
+ />
438
+ );
439
+ })}
440
+ </View>
441
+
442
+ {/* List: status groups, closed beads behind the eye (delta 20260918e §4.4) */}
443
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
444
+ <Text style={[styles.sectionTitle, { flex: 1 }]}>{`${grouped.visible} of ${rows.length} beads`}</Text>
445
+ <Pressable
446
+ accessibilityRole="button"
447
+ accessibilityState={{ selected: showClosed }}
448
+ accessibilityLabel={`${showClosed ? "Hide" : "Show"} closed beads (${grouped.closed})`}
449
+ onPress={() => closedBeadsVisibility.set(!showClosed)}
450
+ style={[styles.secondaryButton, { flexDirection: "row", alignItems: "center", gap: 6 }]}
451
+ >
452
+ <Icon name={showClosed ? "Eye" : "EyeOff"} size={16} color={theme.colors.foreground} />
453
+ <Text style={styles.secondaryButtonText}>{`Closed ${grouped.closed}`}</Text>
454
+ </Pressable>
455
+ </View>
456
+ {beads.data !== undefined && rows.length === 0 ? (
457
+ <Text style={styles.body}>This workspace has no beads yet.</Text>
458
+ ) : null}
459
+ {grouped.visible === 0 && grouped.closed > 0 ? (
460
+ <Text style={styles.body}>{`All ${grouped.closed} matching beads are closed. Show them with the eye button.`}</Text>
461
+ ) : null}
462
+ {/* One flat run of siblings keyed by bead id: a bead that changes group
463
+ keeps its row and its open detail (delta 20260918f F9). */}
464
+ {beadListItems(grouped).map((item) =>
465
+ item.kind === "group" ? (
466
+ <View key={item.key} style={{ gap: layout.compact ? 6 : 8 }}>
467
+ {/* A line of its own between groups, then the group's name and size. */}
468
+ <View style={{ height: 1, backgroundColor: theme.colors.border, marginTop: 4 }} />
469
+ <Text style={[styles.sectionTitle, { color: toneColor(theme, item.tone) }]}>{`${item.label} · ${item.total}`}</Text>
470
+ </View>
471
+ ) : (
472
+ renderRow(item.bead)
473
+ ),
474
+ )}
475
+ {grouped.truncated > 0 ? (
476
+ <Text style={styles.body}>{`Showing the first ${LIST_LIMIT}. Narrow the filters to see the rest.`}</Text>
477
+ ) : null}
478
+ </ScrollView>
479
+ );
480
+
481
+ function renderRow(bead: BeadRow) {
482
+ const open = openId === bead.id;
483
+ const work = workSummary(bead, now);
484
+ return (
485
+ <BeadRowCard
486
+ key={bead.id}
487
+ bead={bead}
488
+ open={open}
489
+ onToggle={() => setOpenId(open ? null : bead.id)}
490
+ styles={styles}
491
+ theme={theme}
492
+ meta={
493
+ <>
494
+ <View style={{ flexDirection: "row", gap: 8, alignItems: "center", flexWrap: "wrap", marginTop: 2 }}>
495
+ <Text style={[styles.body, { fontSize: 12 }]} selectable numberOfLines={1}>
496
+ {bead.id}
497
+ </Text>
498
+ <View style={{ flex: 1 }} />
499
+ <Chip badge={{ text: `${priorityLabel(bead.priority)} · ${bead.issueType}`, tone: "muted" }} styles={styles} theme={theme} />
500
+ <Chip badge={statusBadge(bead)} styles={styles} theme={theme} />
501
+ </View>
502
+ {work === null ? null : (
503
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6, marginTop: 4 }}>
504
+ <RoleMark kind="worker" theme={theme} size={16} />
505
+ <Text style={[styles.body, { flex: 1, color: toneColor(theme, work.tone) }]} numberOfLines={1}>
506
+ {work.headline}
507
+ </Text>
508
+ </View>
509
+ )}
510
+ </>
511
+ }
512
+ detail={<BeadDetailPanel workspaceId={workspaceId} bead={bead} styles={styles} theme={theme} navigation={navigation} />}
513
+ />
514
+ );
515
+ }
516
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The "Beads" tab of a workspace (delta 20260918e §4.1, REQ-060 a–c): the
3
+ * workspace panel Paseo lists in the "+" menu of the tab bar — the dropdown on
4
+ * desktop and the "New tab" screen on mobile — with the workspace's Beads and
5
+ * Metric screens as two sub-tabs.
6
+ *
7
+ * The two screens are the ones the Beads Manager surface shows, rendered
8
+ * without a back button or a title: the tab already lives in its workspace.
9
+ * Their data stays in the React Query cache under their usual keys, so
10
+ * switching sub-tabs does not wait for a reload.
11
+ *
12
+ * Client rules: React Native primitives only, colours from `theme.colors`, no
13
+ * Node import, no `server/` import.
14
+ */
15
+ import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
16
+ import { useMemo, useState } from "react";
17
+ import { Pressable, Text, View } from "react-native";
18
+ import { BeadsScreen } from "./beads-screen";
19
+ import { DashboardPanel } from "./dashboard";
20
+ import { dashboardStyles } from "./dashboard-model";
21
+ import { BEADS_TAB_VIEWS, DEFAULT_BEADS_TAB_VIEW, type BeadsTabView } from "./dashboard-view";
22
+
23
+ export function BeadsTabPanel(props: PluginWorkspacePanelProps) {
24
+ const { theme, layout } = props;
25
+ const styles = useMemo(() => dashboardStyles(theme, layout.compact), [theme, layout.compact]);
26
+ // Lives as long as this tab: a reload opens on Beads again (PRD delta §6).
27
+ const [view, setView] = useState<BeadsTabView>(DEFAULT_BEADS_TAB_VIEW);
28
+ // The same outer padding as the screens below, so the row lines up with them.
29
+ const padding = styles.content.padding;
30
+
31
+ return (
32
+ <View style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
33
+ <View accessibilityRole="tablist" style={{ flexDirection: "row", gap: 8, paddingHorizontal: padding, paddingTop: padding }}>
34
+ {BEADS_TAB_VIEWS.map((tab) => {
35
+ const selected = tab.key === view;
36
+ return (
37
+ <Pressable
38
+ key={tab.key}
39
+ accessibilityRole="tab"
40
+ accessibilityState={{ selected }}
41
+ onPress={() => setView(tab.key)}
42
+ style={selected ? styles.button : styles.secondaryButton}
43
+ >
44
+ <Text style={selected ? styles.buttonText : styles.secondaryButtonText}>{tab.label}</Text>
45
+ </Pressable>
46
+ );
47
+ })}
48
+ </View>
49
+ <View style={{ flex: 1 }}>
50
+ {view === "beads" ? (
51
+ <BeadsScreen {...props} />
52
+ ) : (
53
+ <DashboardPanel {...props} />
54
+ )}
55
+ </View>
56
+ </View>
57
+ );
58
+ }