@omercnet/paseo-beads 0.1.0-next.72.1

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.
@@ -0,0 +1,1222 @@
1
+ import { type PluginWorkspacePanelProps, useRpc, useWorkspace } from "@getpaseo/plugin/client";
2
+ import { Icon } from "@getpaseo/plugin/client/react-native";
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react";
5
+ import {
6
+ AccessibilityInfo,
7
+ ActivityIndicator,
8
+ findNodeHandle,
9
+ InteractionManager,
10
+ Platform,
11
+ Pressable,
12
+ ScrollView,
13
+ SectionList,
14
+ StyleSheet,
15
+ Text,
16
+ TextInput,
17
+ type TextStyle,
18
+ View,
19
+ type ViewStyle,
20
+ } from "react-native";
21
+ import {
22
+ type BeadDetail,
23
+ type BeadSummary,
24
+ getWorkspaceBead,
25
+ getWorkspaceBeads,
26
+ } from "../shared/beads";
27
+ import {
28
+ BEAD_LANE_TITLES,
29
+ BEAD_LANES,
30
+ type BeadLane,
31
+ type BeadSection,
32
+ type BeadsFilter,
33
+ buildBeadSections,
34
+ buildBeadsView,
35
+ issueAccessibilityLabel,
36
+ } from "./beads-view";
37
+ import { focusWebElement } from "./web";
38
+
39
+ const REFRESH_INTERVAL_MS = 10_000;
40
+ const BACK_BUTTON_NATIVE_ID = "paseo-beads-back";
41
+ const ISSUE_ROW_NATIVE_ID_PREFIX = "paseo-beads-issue-";
42
+ const ID_FONT_FAMILY = Platform.select({ ios: "Menlo", default: "monospace" });
43
+
44
+ const FILTERS: readonly { id: BeadsFilter; title: string }[] = [
45
+ { id: "all", title: "All" },
46
+ { id: "high_priority", title: "P0–P1" },
47
+ { id: "assigned", title: "Assigned" },
48
+ ];
49
+
50
+ function errorMessage(error: unknown): string {
51
+ return error instanceof Error ? error.message : "An unexpected error occurred.";
52
+ }
53
+
54
+ function formatUpdatedAt(value: string | null): string {
55
+ if (!value) return "Unknown";
56
+ const timestamp = Date.parse(value);
57
+ if (!Number.isFinite(timestamp)) return value;
58
+ return new Date(timestamp).toLocaleString();
59
+ }
60
+
61
+ function countText(value: number, singular: string, plural = `${singular}s`): string {
62
+ return `${value} ${value === 1 ? singular : plural}`;
63
+ }
64
+
65
+ function laneColor(lane: BeadLane, colors: PluginWorkspacePanelProps["theme"]["colors"]): string {
66
+ if (lane === "ready") return colors.statusSuccess;
67
+ if (lane === "in_progress") return colors.accent;
68
+ if (lane === "blocked") return colors.statusDanger;
69
+ return colors.foregroundMuted;
70
+ }
71
+
72
+ function priorityColor(
73
+ priority: number,
74
+ colors: PluginWorkspacePanelProps["theme"]["colors"],
75
+ ): string {
76
+ if (priority <= 1) return colors.statusDanger;
77
+ if (priority === 2) return colors.statusWarning;
78
+ return colors.foregroundMuted;
79
+ }
80
+
81
+ function readinessLabel(issue: BeadSummary): string {
82
+ if (issue.isReady) return "Ready";
83
+ if (issue.isBlocked) return "Blocked";
84
+ return "Not ready";
85
+ }
86
+
87
+ function readinessColor(
88
+ issue: BeadSummary,
89
+ colors: PluginWorkspacePanelProps["theme"]["colors"],
90
+ ): string {
91
+ if (issue.isReady) return colors.statusSuccess;
92
+ if (issue.isBlocked) return colors.statusDanger;
93
+ return colors.foregroundMuted;
94
+ }
95
+
96
+ function issueRowNativeId(issueId: string): string {
97
+ return `${ISSUE_ROW_NATIVE_ID_PREFIX}${encodeURIComponent(issueId)}`;
98
+ }
99
+
100
+ function focusAccessibilityTarget(target: View | null, nativeId: string): boolean {
101
+ if (focusWebElement(nativeId)) return true;
102
+ if (Platform.OS === "web") return false;
103
+
104
+ const node = target ? findNodeHandle(target) : null;
105
+ if (node === null) return false;
106
+
107
+ AccessibilityInfo.setAccessibilityFocus(node);
108
+ return true;
109
+ }
110
+
111
+ type PanelStyles = Record<string, TextStyle | ViewStyle>;
112
+
113
+ function StateCard({
114
+ body,
115
+ icon,
116
+ loading,
117
+ onRetry,
118
+ styles,
119
+ theme,
120
+ title,
121
+ }: {
122
+ body: string;
123
+ icon: string;
124
+ loading?: boolean;
125
+ onRetry?: () => void;
126
+ styles: PanelStyles;
127
+ theme: PluginWorkspacePanelProps["theme"];
128
+ title: string;
129
+ }) {
130
+ return (
131
+ <View style={styles.stateCard}>
132
+ {loading ? (
133
+ <ActivityIndicator color={theme.colors.accent} />
134
+ ) : (
135
+ <Icon name={icon} size={24} color={theme.colors.foregroundMuted} />
136
+ )}
137
+ <Text style={styles.stateTitle}>{title}</Text>
138
+ <Text style={styles.stateBody}>{body}</Text>
139
+ {onRetry ? (
140
+ <Pressable
141
+ accessibilityRole="button"
142
+ accessibilityLabel="Retry loading Beads"
143
+ onPress={onRetry}
144
+ style={({ pressed }) => [styles.secondaryButton, pressed && styles.pressed]}
145
+ >
146
+ <Text style={styles.secondaryButtonText}>Retry</Text>
147
+ </Pressable>
148
+ ) : null}
149
+ </View>
150
+ );
151
+ }
152
+
153
+ function TextSection({
154
+ content,
155
+ styles,
156
+ title,
157
+ }: {
158
+ content: string | null;
159
+ styles: PanelStyles;
160
+ title: string;
161
+ }) {
162
+ return (
163
+ <View style={styles.detailSection}>
164
+ <Text style={styles.sectionTitle}>{title}</Text>
165
+ <Text selectable style={content?.trim() ? styles.sectionCopy : styles.sectionEmpty}>
166
+ {content?.trim() || "Not provided."}
167
+ </Text>
168
+ </View>
169
+ );
170
+ }
171
+
172
+ function RelationshipList({
173
+ items,
174
+ styles,
175
+ }: {
176
+ items: BeadDetail["dependencies"];
177
+ styles: PanelStyles;
178
+ }) {
179
+ if (items.length === 0) return <Text style={styles.sectionEmpty}>None.</Text>;
180
+
181
+ return (
182
+ <View style={styles.relationshipList}>
183
+ {items.map((item) => (
184
+ <View key={`${item.dependencyType}:${item.id}`} style={styles.relationshipRow}>
185
+ <View style={styles.relationshipHeader}>
186
+ <Text selectable style={styles.relationshipId}>
187
+ {item.id}
188
+ </Text>
189
+ <Text style={styles.relationshipStatus}>{item.status}</Text>
190
+ </View>
191
+ <Text selectable style={styles.relationshipTitle}>
192
+ {item.title}
193
+ </Text>
194
+ <Text style={styles.relationshipMeta}>
195
+ {item.issueType} · {item.dependencyType}
196
+ </Text>
197
+ </View>
198
+ ))}
199
+ </View>
200
+ );
201
+ }
202
+
203
+ function DetailPane({
204
+ detail,
205
+ backButtonRef,
206
+ detailError,
207
+ isDetailFetching,
208
+ isDetailPending,
209
+ missing,
210
+ onBack,
211
+ onRetry,
212
+ selectedId,
213
+ showBack,
214
+ styles,
215
+ theme,
216
+ }: {
217
+ backButtonRef: RefObject<View | null>;
218
+ detail: BeadDetail | null | undefined;
219
+ detailError: unknown;
220
+ isDetailFetching: boolean;
221
+ isDetailPending: boolean;
222
+ missing: boolean;
223
+ onBack(): void;
224
+ onRetry(): void;
225
+ selectedId: string | null;
226
+ showBack: boolean;
227
+ styles: PanelStyles;
228
+ theme: PluginWorkspacePanelProps["theme"];
229
+ }) {
230
+ if (!selectedId) {
231
+ return (
232
+ <View style={styles.detailEmpty}>
233
+ <Icon name="CircleDot" size={26} color={theme.colors.foregroundMuted} />
234
+ <Text style={styles.stateTitle}>Choose a bead</Text>
235
+ <Text style={styles.stateBody}>
236
+ Select an issue to inspect its dependencies, context, and acceptance criteria.
237
+ </Text>
238
+ </View>
239
+ );
240
+ }
241
+
242
+ const backButton = showBack ? (
243
+ <Pressable
244
+ ref={backButtonRef}
245
+ nativeID={BACK_BUTTON_NATIVE_ID}
246
+ accessibilityRole="button"
247
+ accessibilityLabel="Back to Beads list"
248
+ onPress={onBack}
249
+ style={({ pressed }) => [styles.backButton, pressed && styles.pressed]}
250
+ >
251
+ <Icon name="ArrowLeft" size={16} color={theme.colors.foreground} />
252
+ <Text style={styles.backText}>Back</Text>
253
+ </Pressable>
254
+ ) : null;
255
+
256
+ if (isDetailPending && !detail) {
257
+ return (
258
+ <View style={styles.detailRoot}>
259
+ {backButton}
260
+ <StateCard
261
+ body={`Reading ${selectedId}.`}
262
+ icon="CircleDot"
263
+ loading
264
+ styles={styles}
265
+ theme={theme}
266
+ title="Loading bead"
267
+ />
268
+ </View>
269
+ );
270
+ }
271
+
272
+ if (detailError && !detail) {
273
+ return (
274
+ <View style={styles.detailRoot}>
275
+ {backButton}
276
+ <StateCard
277
+ body={errorMessage(detailError)}
278
+ icon="AlertTriangle"
279
+ onRetry={onRetry}
280
+ styles={styles}
281
+ theme={theme}
282
+ title="Could not load this bead"
283
+ />
284
+ </View>
285
+ );
286
+ }
287
+
288
+ if (missing || !detail) {
289
+ return (
290
+ <View style={styles.detailRoot}>
291
+ {backButton}
292
+ <StateCard
293
+ body="This issue is no longer available. Refresh the list or choose another bead."
294
+ icon="CircleDot"
295
+ onRetry={onRetry}
296
+ styles={styles}
297
+ theme={theme}
298
+ title="Bead not found"
299
+ />
300
+ </View>
301
+ );
302
+ }
303
+
304
+ const linkCount = detail.dependencyCount + detail.dependentCount;
305
+
306
+ return (
307
+ <ScrollView
308
+ style={styles.detailScroll}
309
+ contentContainerStyle={styles.detailContent}
310
+ keyboardShouldPersistTaps="handled"
311
+ >
312
+ {backButton}
313
+ <View style={styles.detailHeading}>
314
+ <View style={styles.detailIdRow}>
315
+ <Text selectable style={styles.detailId}>
316
+ {detail.id}
317
+ </Text>
318
+ <View
319
+ style={[styles.readinessBadge, { borderColor: readinessColor(detail, theme.colors) }]}
320
+ >
321
+ <Text style={[styles.readinessText, { color: readinessColor(detail, theme.colors) }]}>
322
+ {readinessLabel(detail)}
323
+ </Text>
324
+ </View>
325
+ </View>
326
+ <Text selectable accessibilityRole="header" style={styles.detailTitle}>
327
+ {detail.title}
328
+ </Text>
329
+ <Text style={styles.detailRefreshState}>
330
+ {isDetailFetching ? "Refreshing detail…" : `Updated ${formatUpdatedAt(detail.updatedAt)}`}
331
+ </Text>
332
+ </View>
333
+
334
+ {detailError ? (
335
+ <View accessibilityRole="alert" style={styles.inlineError}>
336
+ <Text style={styles.inlineErrorTitle}>Detail refresh failed</Text>
337
+ <Text style={styles.inlineErrorBody}>{errorMessage(detailError)}</Text>
338
+ </View>
339
+ ) : null}
340
+
341
+ <View style={styles.factGrid}>
342
+ <View style={styles.fact}>
343
+ <Text style={styles.factLabel}>Status</Text>
344
+ <Text selectable style={styles.factValue}>
345
+ {detail.status}
346
+ </Text>
347
+ </View>
348
+ <View style={styles.fact}>
349
+ <Text style={styles.factLabel}>Priority</Text>
350
+ <Text
351
+ selectable
352
+ style={[styles.factValue, { color: priorityColor(detail.priority, theme.colors) }]}
353
+ >
354
+ P{detail.priority}
355
+ </Text>
356
+ </View>
357
+ <View style={styles.fact}>
358
+ <Text style={styles.factLabel}>Type</Text>
359
+ <Text selectable style={styles.factValue}>
360
+ {detail.issueType}
361
+ </Text>
362
+ </View>
363
+ <View style={styles.fact}>
364
+ <Text style={styles.factLabel}>Assignee</Text>
365
+ <Text selectable style={styles.factValue}>
366
+ {detail.assignee || "Unassigned"}
367
+ </Text>
368
+ </View>
369
+ <View style={styles.fact}>
370
+ <Text style={styles.factLabel}>Parent</Text>
371
+ <Text selectable style={styles.factValue}>
372
+ {detail.parent || "None"}
373
+ </Text>
374
+ </View>
375
+ <View style={styles.fact}>
376
+ <Text style={styles.factLabel}>Activity</Text>
377
+ <Text style={styles.factValue}>
378
+ {countText(linkCount, "relationship")} · {countText(detail.commentCount, "comment")}
379
+ </Text>
380
+ </View>
381
+ </View>
382
+
383
+ <View style={styles.detailSection}>
384
+ <Text style={styles.sectionTitle}>Labels</Text>
385
+ {detail.labels.length ? (
386
+ <View style={styles.labelRail}>
387
+ {detail.labels.map((label) => (
388
+ <View key={label} style={styles.labelBadge}>
389
+ <Text selectable style={styles.labelText}>
390
+ {label}
391
+ </Text>
392
+ </View>
393
+ ))}
394
+ </View>
395
+ ) : (
396
+ <Text style={styles.sectionEmpty}>None.</Text>
397
+ )}
398
+ </View>
399
+
400
+ <TextSection title="Description" content={detail.description} styles={styles} />
401
+ <TextSection
402
+ title="Acceptance criteria"
403
+ content={detail.acceptanceCriteria}
404
+ styles={styles}
405
+ />
406
+ <TextSection title="Design" content={detail.design} styles={styles} />
407
+ <TextSection title="Notes" content={detail.notes} styles={styles} />
408
+
409
+ <View style={styles.detailSection}>
410
+ <View style={styles.sectionHeadingRow}>
411
+ <Text style={styles.sectionTitle}>Dependencies</Text>
412
+ <Text style={styles.sectionCount}>{detail.dependencies.length}</Text>
413
+ </View>
414
+ <RelationshipList items={detail.dependencies} styles={styles} />
415
+ </View>
416
+ <View style={styles.detailSection}>
417
+ <View style={styles.sectionHeadingRow}>
418
+ <Text style={styles.sectionTitle}>Dependents</Text>
419
+ <Text style={styles.sectionCount}>{detail.dependents.length}</Text>
420
+ </View>
421
+ <RelationshipList items={detail.dependents} styles={styles} />
422
+ </View>
423
+ <View style={styles.detailSection}>
424
+ <Text style={styles.sectionTitle}>Updated</Text>
425
+ <Text selectable style={styles.sectionCopy}>
426
+ {formatUpdatedAt(detail.updatedAt)}
427
+ </Text>
428
+ </View>
429
+ </ScrollView>
430
+ );
431
+ }
432
+
433
+ export function PaseoBeads(props: PluginWorkspacePanelProps) {
434
+ return <WorkspaceBeads key={props.workspaceId} {...props} />;
435
+ }
436
+
437
+ function WorkspaceBeads({ theme, layout, host, workspaceId }: PluginWorkspacePanelProps) {
438
+ const styles = useMemo(() => createStyles(theme, layout.compact), [layout.compact, theme]);
439
+ const workspaceTitle = useWorkspace(workspaceId, ({ name, title }) => title?.trim() || name);
440
+ const loadSnapshot = useRpc(getWorkspaceBeads);
441
+ const loadDetail = useRpc(getWorkspaceBead);
442
+ const [search, setSearch] = useState("");
443
+ const [filter, setFilter] = useState<BeadsFilter>("all");
444
+ const [selectedId, setSelectedId] = useState<string | null>(null);
445
+ const detailFocusRef = useRef<View | null>(null);
446
+ const issueRowRefs = useRef(new Map<string, View>());
447
+ const originatingIssueIdRef = useRef<string | null>(null);
448
+ const pendingFocusIssueIdRef = useRef<string | null>(null);
449
+ const listScrollOffsetRef = useRef(0);
450
+
451
+ const snapshotKey = useMemo(
452
+ () => ["paseo-beads", "snapshot", host.id, workspaceId] as const,
453
+ [host.id, workspaceId],
454
+ );
455
+ const {
456
+ data: snapshot,
457
+ error: snapshotError,
458
+ isFetching: isSnapshotFetching,
459
+ isPending: isSnapshotPending,
460
+ refetch: refetchSnapshot,
461
+ } = useQuery({
462
+ queryKey: snapshotKey,
463
+ queryFn: () => loadSnapshot({ workspaceId }),
464
+ refetchInterval: REFRESH_INTERVAL_MS,
465
+ });
466
+
467
+ const view = useMemo(
468
+ () => buildBeadsView(snapshot?.issues ?? [], { query: search, filter }),
469
+ [filter, search, snapshot?.issues],
470
+ );
471
+ const activeFiltering = filter !== "all" || search.trim().length > 0;
472
+ const sections = useMemo(() => buildBeadSections(view, activeFiltering), [activeFiltering, view]);
473
+ const detailKey = useMemo(
474
+ () => ["paseo-beads", "detail", host.id, workspaceId, selectedId] as const,
475
+ [host.id, selectedId, workspaceId],
476
+ );
477
+ const {
478
+ data: detailResult,
479
+ error: detailError,
480
+ isFetching: isDetailFetching,
481
+ isPending: isDetailPending,
482
+ refetch: refetchDetail,
483
+ } = useQuery({
484
+ queryKey: detailKey,
485
+ queryFn: () => loadDetail({ workspaceId, issueId: selectedId as string }),
486
+ enabled: selectedId !== null && snapshot?.state === "ready",
487
+ refetchInterval: REFRESH_INTERVAL_MS,
488
+ });
489
+
490
+ const totalCount = snapshot?.issues.length ?? 0;
491
+ const title = workspaceTitle?.trim() || "Current workspace";
492
+ const showListControls = snapshot?.state === "ready" && snapshot.issues.length > 0;
493
+
494
+ useEffect(() => {
495
+ if (!layout.compact || selectedId === null) return;
496
+ if (focusAccessibilityTarget(detailFocusRef.current, BACK_BUTTON_NATIVE_ID)) return;
497
+
498
+ const interaction = InteractionManager.runAfterInteractions(() => {
499
+ focusAccessibilityTarget(detailFocusRef.current, BACK_BUTTON_NATIVE_ID);
500
+ });
501
+ return () => interaction.cancel();
502
+ }, [layout.compact, selectedId]);
503
+
504
+ function registerIssueRow(issueId: string, nativeId: string, row: View | null) {
505
+ if (row) issueRowRefs.current.set(issueId, row);
506
+ else issueRowRefs.current.delete(issueId);
507
+
508
+ if (
509
+ !row ||
510
+ !layout.compact ||
511
+ selectedId !== null ||
512
+ pendingFocusIssueIdRef.current !== issueId
513
+ ) {
514
+ return;
515
+ }
516
+
517
+ if (focusAccessibilityTarget(row, nativeId)) {
518
+ pendingFocusIssueIdRef.current = null;
519
+ originatingIssueIdRef.current = null;
520
+ return;
521
+ }
522
+
523
+ InteractionManager.runAfterInteractions(() => {
524
+ if (
525
+ pendingFocusIssueIdRef.current === issueId &&
526
+ issueRowRefs.current.get(issueId) === row &&
527
+ focusAccessibilityTarget(row, nativeId)
528
+ ) {
529
+ pendingFocusIssueIdRef.current = null;
530
+ originatingIssueIdRef.current = null;
531
+ }
532
+ });
533
+ }
534
+
535
+ function openIssue(issueId: string) {
536
+ originatingIssueIdRef.current = issueId;
537
+ setSelectedId(issueId);
538
+ }
539
+
540
+ function returnToList() {
541
+ pendingFocusIssueIdRef.current = originatingIssueIdRef.current ?? selectedId;
542
+ setSelectedId(null);
543
+ }
544
+
545
+ function renderIssue(issue: BeadSummary, lane: BeadLane, index: number, itemCount: number) {
546
+ const selected = selectedId === issue.id;
547
+ const nativeId = issueRowNativeId(issue.id);
548
+ const metadata = [issue.issueType, issue.assignee ? `@${issue.assignee}` : null].filter(
549
+ (value): value is string => Boolean(value),
550
+ );
551
+ const activity = [
552
+ issue.dependencyCount > 0 ? countText(issue.dependencyCount, "dependency") : null,
553
+ issue.dependentCount > 0 ? countText(issue.dependentCount, "dependent") : null,
554
+ issue.commentCount > 0 ? countText(issue.commentCount, "comment") : null,
555
+ ].filter((value): value is string => Boolean(value));
556
+
557
+ return (
558
+ <Pressable
559
+ ref={(row) => registerIssueRow(issue.id, nativeId, row)}
560
+ accessibilityRole="button"
561
+ accessibilityLabel={issueAccessibilityLabel(issue, lane)}
562
+ accessibilityState={{ selected }}
563
+ nativeID={nativeId}
564
+ onPress={() => openIssue(issue.id)}
565
+ style={({ pressed }) => [
566
+ styles.issueRow,
567
+ index === 0 && styles.issueRowFirst,
568
+ index === itemCount - 1 && styles.issueRowLast,
569
+ selected && styles.issueRowSelected,
570
+ pressed && styles.pressed,
571
+ ]}
572
+ >
573
+ <View style={[styles.issueRail, { backgroundColor: laneColor(lane, theme.colors) }]} />
574
+ <View style={styles.issueBody}>
575
+ <View style={styles.issueTopLine}>
576
+ <Text style={[styles.priority, { color: priorityColor(issue.priority, theme.colors) }]}>
577
+ P{issue.priority}
578
+ </Text>
579
+ <Text selectable style={styles.issueId} numberOfLines={1}>
580
+ {issue.id}
581
+ </Text>
582
+ </View>
583
+ <Text selectable style={styles.issueTitle} numberOfLines={2}>
584
+ {issue.title}
585
+ </Text>
586
+ <Text style={styles.issueMeta} numberOfLines={1}>
587
+ {metadata.join(" · ")}
588
+ </Text>
589
+ {issue.labels.length ? (
590
+ <View style={styles.rowLabels}>
591
+ {issue.labels.map((label) => (
592
+ <View key={label} style={styles.rowLabelBadge}>
593
+ <Text style={styles.rowLabelText} numberOfLines={1}>
594
+ {label}
595
+ </Text>
596
+ </View>
597
+ ))}
598
+ </View>
599
+ ) : null}
600
+ {activity.length ? (
601
+ <Text style={styles.issueActivity} numberOfLines={1}>
602
+ {activity.join(" · ")}
603
+ </Text>
604
+ ) : null}
605
+ </View>
606
+ <Icon name="ChevronRight" size={15} color={theme.colors.foregroundMuted} />
607
+ </Pressable>
608
+ );
609
+ }
610
+
611
+ function renderList() {
612
+ const listHeader =
613
+ snapshot?.truncated || snapshotError ? (
614
+ <View style={styles.listNotices}>
615
+ {snapshot?.truncated ? (
616
+ <View accessibilityRole="alert" style={styles.truncationNotice}>
617
+ <Icon name="AlertTriangle" size={16} color={theme.colors.statusWarning} />
618
+ <Text style={styles.truncationText}>
619
+ Showing the first 500 issues. Counts and search results may be incomplete.
620
+ </Text>
621
+ </View>
622
+ ) : null}
623
+ {snapshotError ? (
624
+ <View accessibilityRole="alert" style={styles.inlineError}>
625
+ <Text style={styles.inlineErrorTitle}>Refresh failed</Text>
626
+ <Text style={styles.inlineErrorBody}>{errorMessage(snapshotError)}</Text>
627
+ </View>
628
+ ) : null}
629
+ </View>
630
+ ) : null;
631
+
632
+ return (
633
+ <SectionList<BeadSummary, BeadSection>
634
+ style={styles.listScroll}
635
+ contentContainerStyle={styles.listContent}
636
+ sections={sections}
637
+ keyExtractor={(issue) => issue.id}
638
+ keyboardShouldPersistTaps="handled"
639
+ stickySectionHeadersEnabled={false}
640
+ initialNumToRender={12}
641
+ maxToRenderPerBatch={10}
642
+ windowSize={5}
643
+ contentOffset={layout.compact ? { x: 0, y: listScrollOffsetRef.current } : undefined}
644
+ onScroll={(event) => {
645
+ listScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
646
+ }}
647
+ scrollEventThrottle={16}
648
+ ListHeaderComponent={listHeader}
649
+ ListEmptyComponent={
650
+ activeFiltering ? (
651
+ <StateCard
652
+ body="Change the search text or filter to see more issues."
653
+ icon="Search"
654
+ styles={styles}
655
+ theme={theme}
656
+ title="No matching beads"
657
+ />
658
+ ) : null
659
+ }
660
+ renderSectionHeader={({ section }) => (
661
+ <View style={styles.laneHeading}>
662
+ <View
663
+ style={[styles.laneDot, { backgroundColor: laneColor(section.lane, theme.colors) }]}
664
+ />
665
+ <Text accessibilityRole="header" style={styles.laneTitle}>
666
+ {section.title}
667
+ </Text>
668
+ <Text style={styles.laneCount}>{section.data.length}</Text>
669
+ </View>
670
+ )}
671
+ renderSectionFooter={({ section }) =>
672
+ section.data.length === 0 ? (
673
+ <Text style={styles.emptyLane}>No issues in this lane.</Text>
674
+ ) : null
675
+ }
676
+ renderItem={({ item, index, section }) =>
677
+ renderIssue(item, section.lane, index, section.data.length)
678
+ }
679
+ />
680
+ );
681
+ }
682
+
683
+ let body: ReactNode;
684
+ if (isSnapshotPending && !snapshot) {
685
+ body = (
686
+ <StateCard
687
+ body="Reading this workspace’s Beads issue graph."
688
+ icon="CircleDot"
689
+ loading
690
+ styles={styles}
691
+ theme={theme}
692
+ title="Loading Beads"
693
+ />
694
+ );
695
+ } else if (snapshotError && !snapshot) {
696
+ body = (
697
+ <StateCard
698
+ body={errorMessage(snapshotError)}
699
+ icon="AlertTriangle"
700
+ onRetry={() => void refetchSnapshot()}
701
+ styles={styles}
702
+ theme={theme}
703
+ title="Could not load Beads"
704
+ />
705
+ );
706
+ } else if (snapshot?.state === "bd_unavailable") {
707
+ body = (
708
+ <StateCard
709
+ body={snapshot.message || "The bd CLI is not available on this Paseo host."}
710
+ icon="AlertTriangle"
711
+ onRetry={() => void refetchSnapshot()}
712
+ styles={styles}
713
+ theme={theme}
714
+ title="Beads is unavailable"
715
+ />
716
+ );
717
+ } else if (snapshot?.state === "not_initialized") {
718
+ body = (
719
+ <StateCard
720
+ body={snapshot.message || "Beads is not initialized for this workspace."}
721
+ icon="CircleDot"
722
+ onRetry={() => void refetchSnapshot()}
723
+ styles={styles}
724
+ theme={theme}
725
+ title="No Beads project"
726
+ />
727
+ );
728
+ } else if (snapshot?.state === "ready" && snapshot.issues.length === 0 && !selectedId) {
729
+ body = (
730
+ <StateCard
731
+ body="This workspace has no Beads issues yet."
732
+ icon="CircleDot"
733
+ styles={styles}
734
+ theme={theme}
735
+ title="No beads"
736
+ />
737
+ );
738
+ } else if (snapshot?.state === "ready") {
739
+ const detailPane = (
740
+ <DetailPane
741
+ backButtonRef={detailFocusRef}
742
+ detail={detailResult?.detail}
743
+ detailError={detailError}
744
+ isDetailFetching={isDetailFetching}
745
+ isDetailPending={isDetailPending}
746
+ missing={detailResult?.detail === null}
747
+ onBack={returnToList}
748
+ onRetry={() => void refetchDetail()}
749
+ selectedId={selectedId}
750
+ showBack={layout.compact}
751
+ styles={styles}
752
+ theme={theme}
753
+ />
754
+ );
755
+ body = layout.compact ? (
756
+ selectedId ? (
757
+ detailPane
758
+ ) : (
759
+ renderList()
760
+ )
761
+ ) : (
762
+ <View style={styles.split}>
763
+ <View style={styles.listPane}>{renderList()}</View>
764
+ <View style={styles.detailPane}>{detailPane}</View>
765
+ </View>
766
+ );
767
+ } else {
768
+ body = (
769
+ <StateCard
770
+ body="The workspace did not return a usable Beads snapshot."
771
+ icon="AlertTriangle"
772
+ onRetry={() => void refetchSnapshot()}
773
+ styles={styles}
774
+ theme={theme}
775
+ title="Unexpected Beads state"
776
+ />
777
+ );
778
+ }
779
+
780
+ return (
781
+ <View style={styles.screen}>
782
+ <View style={styles.header}>
783
+ <View style={styles.titleRow}>
784
+ <View style={styles.titleBlock}>
785
+ <Text style={styles.eyebrow}>Dependency focus</Text>
786
+ <Text accessibilityRole="header" style={styles.workspaceTitle} numberOfLines={1}>
787
+ {title}
788
+ </Text>
789
+ <Text style={styles.refreshState} numberOfLines={1}>
790
+ {isSnapshotFetching
791
+ ? "Refreshing…"
792
+ : snapshot
793
+ ? `Updated ${formatUpdatedAt(snapshot.refreshedAt)}`
794
+ : "Waiting for first refresh"}
795
+ </Text>
796
+ </View>
797
+ <Pressable
798
+ accessibilityRole="button"
799
+ accessibilityLabel={isSnapshotFetching ? "Refreshing Beads" : "Refresh Beads"}
800
+ onPress={() => void refetchSnapshot()}
801
+ style={({ pressed }) => [styles.refreshButton, pressed && styles.pressed]}
802
+ >
803
+ <Icon
804
+ name="RefreshCw"
805
+ size={15}
806
+ color={isSnapshotFetching ? theme.colors.accent : theme.colors.foreground}
807
+ />
808
+ <Text style={styles.refreshButtonText}>
809
+ {isSnapshotFetching ? "Refreshing" : "Refresh"}
810
+ </Text>
811
+ </Pressable>
812
+ </View>
813
+
814
+ {showListControls ? (
815
+ <>
816
+ <ScrollView
817
+ horizontal
818
+ showsHorizontalScrollIndicator={false}
819
+ contentContainerStyle={styles.countRail}
820
+ >
821
+ {BEAD_LANES.map((lane) => (
822
+ <View key={lane} style={styles.countItem}>
823
+ <View
824
+ style={[styles.countDot, { backgroundColor: laneColor(lane, theme.colors) }]}
825
+ />
826
+ <Text style={styles.countLabel}>
827
+ {BEAD_LANE_TITLES[lane]} {view.counts[lane]}
828
+ </Text>
829
+ </View>
830
+ ))}
831
+ </ScrollView>
832
+
833
+ <View style={styles.searchRow}>
834
+ <Icon name="Search" size={15} color={theme.colors.foregroundMuted} />
835
+ <TextInput
836
+ accessibilityLabel="Search Beads"
837
+ autoCapitalize="none"
838
+ autoCorrect={false}
839
+ onChangeText={setSearch}
840
+ placeholder="Search id, title, assignee, or label"
841
+ placeholderTextColor={theme.colors.foregroundMuted}
842
+ value={search}
843
+ style={styles.searchInput}
844
+ />
845
+ </View>
846
+
847
+ <View style={styles.filterRow}>
848
+ {FILTERS.map(({ id, title: filterTitle }) => {
849
+ const selected = filter === id;
850
+ return (
851
+ <Pressable
852
+ key={id}
853
+ accessibilityRole="button"
854
+ accessibilityLabel={`Filter Beads by ${filterTitle}`}
855
+ accessibilityState={{ selected }}
856
+ onPress={() => setFilter(id)}
857
+ style={({ pressed }) => [
858
+ styles.filterChip,
859
+ selected && styles.filterChipSelected,
860
+ pressed && styles.pressed,
861
+ ]}
862
+ >
863
+ <Text style={[styles.filterText, selected && styles.filterTextSelected]}>
864
+ {filterTitle}
865
+ {id === "all" ? ` ${totalCount}` : ""}
866
+ </Text>
867
+ </Pressable>
868
+ );
869
+ })}
870
+ </View>
871
+ </>
872
+ ) : null}
873
+ </View>
874
+ <View style={styles.body}>{body}</View>
875
+ </View>
876
+ );
877
+ }
878
+
879
+ function createStyles(theme: PluginWorkspacePanelProps["theme"], compact: boolean) {
880
+ return StyleSheet.create({
881
+ screen: {
882
+ flex: 1,
883
+ backgroundColor: theme.colors.surface0,
884
+ },
885
+ header: {
886
+ paddingHorizontal: compact ? 14 : 20,
887
+ paddingTop: compact ? 14 : 18,
888
+ paddingBottom: 12,
889
+ gap: 10,
890
+ borderBottomWidth: StyleSheet.hairlineWidth,
891
+ borderBottomColor: theme.colors.border,
892
+ },
893
+ titleRow: {
894
+ flexDirection: "row",
895
+ alignItems: "center",
896
+ justifyContent: "space-between",
897
+ gap: 12,
898
+ },
899
+ titleBlock: { flex: 1, minWidth: 0, gap: 2 },
900
+ eyebrow: {
901
+ color: theme.colors.foregroundMuted,
902
+ fontSize: 10,
903
+ fontWeight: "700",
904
+ letterSpacing: 1,
905
+ textTransform: "uppercase",
906
+ },
907
+ workspaceTitle: {
908
+ color: theme.colors.foreground,
909
+ fontSize: compact ? 19 : 22,
910
+ fontWeight: "700",
911
+ },
912
+ refreshState: { color: theme.colors.foregroundMuted, fontSize: 11 },
913
+ refreshButton: {
914
+ minHeight: 36,
915
+ flexDirection: "row",
916
+ alignItems: "center",
917
+ justifyContent: "center",
918
+ gap: 6,
919
+ paddingHorizontal: 11,
920
+ borderWidth: StyleSheet.hairlineWidth,
921
+ borderColor: theme.colors.border,
922
+ borderRadius: 8,
923
+ backgroundColor: theme.colors.surface1,
924
+ },
925
+ refreshButtonText: { color: theme.colors.foreground, fontSize: 12, fontWeight: "600" },
926
+ countRail: { gap: 6 },
927
+ countItem: {
928
+ minHeight: 27,
929
+ flexDirection: "row",
930
+ alignItems: "center",
931
+ gap: 6,
932
+ paddingHorizontal: 8,
933
+ borderRadius: 7,
934
+ borderWidth: StyleSheet.hairlineWidth,
935
+ borderColor: theme.colors.border,
936
+ backgroundColor: theme.colors.surface1,
937
+ },
938
+ countDot: { width: 7, height: 7, borderRadius: 4 },
939
+ countLabel: { color: theme.colors.foregroundMuted, fontSize: 11, fontWeight: "600" },
940
+ searchRow: {
941
+ height: 36,
942
+ flexDirection: "row",
943
+ alignItems: "center",
944
+ gap: 8,
945
+ paddingHorizontal: 10,
946
+ borderWidth: StyleSheet.hairlineWidth,
947
+ borderColor: theme.colors.border,
948
+ borderRadius: 8,
949
+ backgroundColor: theme.colors.surface1,
950
+ },
951
+ searchInput: {
952
+ flex: 1,
953
+ minWidth: 0,
954
+ paddingVertical: 0,
955
+ color: theme.colors.foreground,
956
+ fontSize: 13,
957
+ },
958
+ filterRow: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
959
+ filterChip: {
960
+ minHeight: 30,
961
+ alignItems: "center",
962
+ justifyContent: "center",
963
+ paddingHorizontal: 10,
964
+ borderRadius: 15,
965
+ borderWidth: StyleSheet.hairlineWidth,
966
+ borderColor: theme.colors.border,
967
+ backgroundColor: theme.colors.surface1,
968
+ },
969
+ filterChipSelected: {
970
+ borderColor: theme.colors.accent,
971
+ backgroundColor: theme.colors.accent,
972
+ },
973
+ filterText: { color: theme.colors.foregroundMuted, fontSize: 12, fontWeight: "600" },
974
+ filterTextSelected: { color: theme.colors.accentForeground },
975
+ body: { flex: 1, minHeight: 0 },
976
+ split: { flex: 1, minHeight: 0, flexDirection: "row" },
977
+ listPane: {
978
+ width: "46%",
979
+ minWidth: 320,
980
+ borderRightWidth: StyleSheet.hairlineWidth,
981
+ borderRightColor: theme.colors.border,
982
+ },
983
+ detailPane: { flex: 1, minWidth: 0 },
984
+ listScroll: { flex: 1 },
985
+ listContent: { padding: compact ? 10 : 14, paddingBottom: 32 },
986
+ listNotices: { gap: 10 },
987
+ laneHeading: {
988
+ flexDirection: "row",
989
+ alignItems: "center",
990
+ gap: 7,
991
+ marginTop: 14,
992
+ marginBottom: 7,
993
+ paddingHorizontal: 3,
994
+ },
995
+ laneDot: { width: 8, height: 8, borderRadius: 4 },
996
+ laneTitle: { color: theme.colors.foreground, fontSize: 12, fontWeight: "700", flex: 1 },
997
+ laneCount: { color: theme.colors.foregroundMuted, fontSize: 11, fontWeight: "600" },
998
+ emptyLane: {
999
+ paddingVertical: 14,
1000
+ paddingHorizontal: 12,
1001
+ color: theme.colors.foregroundMuted,
1002
+ fontSize: 12,
1003
+ borderWidth: StyleSheet.hairlineWidth,
1004
+ borderColor: theme.colors.border,
1005
+ borderRadius: 9,
1006
+ backgroundColor: theme.colors.surface1,
1007
+ },
1008
+ issueRow: {
1009
+ minHeight: 86,
1010
+ flexDirection: "row",
1011
+ alignItems: "center",
1012
+ gap: 9,
1013
+ overflow: "hidden",
1014
+ paddingRight: 10,
1015
+ borderLeftWidth: StyleSheet.hairlineWidth,
1016
+ borderRightWidth: StyleSheet.hairlineWidth,
1017
+ borderBottomWidth: StyleSheet.hairlineWidth,
1018
+ borderColor: theme.colors.border,
1019
+ backgroundColor: theme.colors.surface1,
1020
+ },
1021
+ issueRowFirst: {
1022
+ borderTopWidth: StyleSheet.hairlineWidth,
1023
+ borderTopLeftRadius: 9,
1024
+ borderTopRightRadius: 9,
1025
+ },
1026
+ issueRowLast: { borderBottomLeftRadius: 9, borderBottomRightRadius: 9 },
1027
+ issueRowSelected: { backgroundColor: theme.colors.surface2 },
1028
+ issueRail: { alignSelf: "stretch", width: 3 },
1029
+ issueBody: { flex: 1, minWidth: 0, paddingVertical: 9, gap: 4 },
1030
+ issueTopLine: { flexDirection: "row", alignItems: "center", gap: 7 },
1031
+ priority: { fontSize: 11, fontWeight: "800" },
1032
+ issueId: {
1033
+ flex: 1,
1034
+ color: theme.colors.foregroundMuted,
1035
+ fontSize: 11,
1036
+ fontFamily: ID_FONT_FAMILY,
1037
+ },
1038
+ issueTitle: { color: theme.colors.foreground, fontSize: 13, fontWeight: "600", lineHeight: 18 },
1039
+ issueMeta: { color: theme.colors.foregroundMuted, fontSize: 11 },
1040
+ issueActivity: { color: theme.colors.foregroundMuted, fontSize: 10 },
1041
+ rowLabels: { flexDirection: "row", flexWrap: "wrap", gap: 4 },
1042
+ rowLabelBadge: {
1043
+ maxWidth: 150,
1044
+ paddingHorizontal: 6,
1045
+ paddingVertical: 2,
1046
+ borderRadius: 4,
1047
+ backgroundColor: theme.colors.surface2,
1048
+ },
1049
+ rowLabelText: { color: theme.colors.foregroundMuted, fontSize: 9, fontWeight: "600" },
1050
+ truncationNotice: {
1051
+ flexDirection: "row",
1052
+ alignItems: "flex-start",
1053
+ gap: 8,
1054
+ padding: 10,
1055
+ borderWidth: StyleSheet.hairlineWidth,
1056
+ borderColor: theme.colors.statusWarning,
1057
+ borderRadius: 8,
1058
+ backgroundColor: theme.colors.surface1,
1059
+ },
1060
+ truncationText: { flex: 1, color: theme.colors.foreground, fontSize: 12, lineHeight: 17 },
1061
+ inlineError: {
1062
+ gap: 3,
1063
+ padding: 10,
1064
+ borderWidth: StyleSheet.hairlineWidth,
1065
+ borderColor: theme.colors.statusDanger,
1066
+ borderRadius: 8,
1067
+ backgroundColor: theme.colors.surface1,
1068
+ },
1069
+ inlineErrorTitle: { color: theme.colors.statusDanger, fontSize: 12, fontWeight: "700" },
1070
+ inlineErrorBody: { color: theme.colors.foreground, fontSize: 12, lineHeight: 17 },
1071
+ stateCard: {
1072
+ alignSelf: "center",
1073
+ alignItems: "center",
1074
+ justifyContent: "center",
1075
+ maxWidth: 460,
1076
+ minHeight: 180,
1077
+ padding: 24,
1078
+ gap: 8,
1079
+ },
1080
+ stateTitle: {
1081
+ color: theme.colors.foreground,
1082
+ fontSize: 16,
1083
+ fontWeight: "700",
1084
+ textAlign: "center",
1085
+ },
1086
+ stateBody: {
1087
+ color: theme.colors.foregroundMuted,
1088
+ fontSize: 13,
1089
+ lineHeight: 19,
1090
+ textAlign: "center",
1091
+ },
1092
+ secondaryButton: {
1093
+ minHeight: 34,
1094
+ alignItems: "center",
1095
+ justifyContent: "center",
1096
+ paddingHorizontal: 12,
1097
+ borderWidth: StyleSheet.hairlineWidth,
1098
+ borderColor: theme.colors.border,
1099
+ borderRadius: 8,
1100
+ backgroundColor: theme.colors.surface1,
1101
+ },
1102
+ secondaryButtonText: { color: theme.colors.foreground, fontSize: 12, fontWeight: "600" },
1103
+ detailRoot: { flex: 1 },
1104
+ detailEmpty: {
1105
+ flex: 1,
1106
+ alignItems: "center",
1107
+ justifyContent: "center",
1108
+ padding: 28,
1109
+ gap: 8,
1110
+ },
1111
+ detailScroll: { flex: 1 },
1112
+ detailContent: { padding: compact ? 14 : 20, paddingBottom: 40, gap: 18 },
1113
+ backButton: {
1114
+ alignSelf: "flex-start",
1115
+ minHeight: 34,
1116
+ flexDirection: "row",
1117
+ alignItems: "center",
1118
+ gap: 6,
1119
+ paddingHorizontal: 10,
1120
+ borderWidth: StyleSheet.hairlineWidth,
1121
+ borderColor: theme.colors.border,
1122
+ borderRadius: 8,
1123
+ backgroundColor: theme.colors.surface1,
1124
+ },
1125
+ backText: { color: theme.colors.foreground, fontSize: 12, fontWeight: "600" },
1126
+ detailHeading: { gap: 7 },
1127
+ detailIdRow: {
1128
+ flexDirection: "row",
1129
+ alignItems: "center",
1130
+ justifyContent: "space-between",
1131
+ gap: 10,
1132
+ },
1133
+ detailId: {
1134
+ color: theme.colors.foregroundMuted,
1135
+ fontSize: 12,
1136
+ fontFamily: ID_FONT_FAMILY,
1137
+ fontWeight: "600",
1138
+ },
1139
+ detailTitle: {
1140
+ color: theme.colors.foreground,
1141
+ fontSize: compact ? 19 : 22,
1142
+ lineHeight: compact ? 25 : 29,
1143
+ fontWeight: "700",
1144
+ },
1145
+ detailRefreshState: { color: theme.colors.foregroundMuted, fontSize: 10 },
1146
+ readinessBadge: {
1147
+ paddingHorizontal: 8,
1148
+ paddingVertical: 3,
1149
+ borderWidth: StyleSheet.hairlineWidth,
1150
+ borderRadius: 10,
1151
+ },
1152
+ readinessText: { fontSize: 10, fontWeight: "700" },
1153
+ factGrid: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
1154
+ fact: {
1155
+ width: compact ? "47%" : "31%",
1156
+ minWidth: 110,
1157
+ padding: 9,
1158
+ gap: 3,
1159
+ borderWidth: StyleSheet.hairlineWidth,
1160
+ borderColor: theme.colors.border,
1161
+ borderRadius: 7,
1162
+ backgroundColor: theme.colors.surface1,
1163
+ },
1164
+ factLabel: {
1165
+ color: theme.colors.foregroundMuted,
1166
+ fontSize: 9,
1167
+ fontWeight: "700",
1168
+ textTransform: "uppercase",
1169
+ letterSpacing: 0.6,
1170
+ },
1171
+ factValue: { color: theme.colors.foreground, fontSize: 12, fontWeight: "600" },
1172
+ detailSection: { gap: 7 },
1173
+ sectionHeadingRow: {
1174
+ flexDirection: "row",
1175
+ alignItems: "center",
1176
+ justifyContent: "space-between",
1177
+ gap: 8,
1178
+ },
1179
+ sectionTitle: { color: theme.colors.foreground, fontSize: 12, fontWeight: "700" },
1180
+ sectionCount: { color: theme.colors.foregroundMuted, fontSize: 11, fontWeight: "600" },
1181
+ sectionCopy: { color: theme.colors.foreground, fontSize: 13, lineHeight: 20 },
1182
+ sectionEmpty: { color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 18 },
1183
+ labelRail: { flexDirection: "row", flexWrap: "wrap", gap: 5 },
1184
+ labelBadge: {
1185
+ paddingHorizontal: 7,
1186
+ paddingVertical: 4,
1187
+ borderRadius: 5,
1188
+ backgroundColor: theme.colors.surface2,
1189
+ },
1190
+ labelText: { color: theme.colors.foreground, fontSize: 11, fontWeight: "600" },
1191
+ relationshipList: {
1192
+ overflow: "hidden",
1193
+ borderWidth: StyleSheet.hairlineWidth,
1194
+ borderColor: theme.colors.border,
1195
+ borderRadius: 8,
1196
+ backgroundColor: theme.colors.surface1,
1197
+ },
1198
+ relationshipRow: {
1199
+ padding: 10,
1200
+ gap: 3,
1201
+ borderBottomWidth: StyleSheet.hairlineWidth,
1202
+ borderBottomColor: theme.colors.border,
1203
+ },
1204
+ relationshipHeader: {
1205
+ flexDirection: "row",
1206
+ alignItems: "center",
1207
+ justifyContent: "space-between",
1208
+ gap: 8,
1209
+ },
1210
+ relationshipId: {
1211
+ flex: 1,
1212
+ color: theme.colors.foregroundMuted,
1213
+ fontSize: 10,
1214
+ fontFamily: ID_FONT_FAMILY,
1215
+ fontWeight: "600",
1216
+ },
1217
+ relationshipStatus: { color: theme.colors.foregroundMuted, fontSize: 10 },
1218
+ relationshipTitle: { color: theme.colors.foreground, fontSize: 12, fontWeight: "600" },
1219
+ relationshipMeta: { color: theme.colors.foregroundMuted, fontSize: 10 },
1220
+ pressed: { opacity: 0.72 },
1221
+ });
1222
+ }