paseo-beads 0.1.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.
@@ -0,0 +1,961 @@
1
+ import { type PluginWorkspacePanelProps, useRpc, useWorkspace } from "@getpaseo/plugin/client";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
4
+ import { Pressable, ScrollView, Text, TextInput, View } from "react-native";
5
+ import {
6
+ SEARCH_LIMIT_DEFAULT,
7
+ SEARCH_QUERY_MAX_LENGTH,
8
+ type CommandError,
9
+ type IssueDetail,
10
+ type SearchResult,
11
+ } from "../shared/beads";
12
+ import { dashboardRpc, issueRpc, searchRpc, type DashboardResult } from "../shared/rpc";
13
+ import {
14
+ dashboardRefreshRevision,
15
+ issueFocusRevision,
16
+ subscribeIssueFocus,
17
+ takeDashboardRefresh,
18
+ takeIssueFocus,
19
+ } from "./focus";
20
+ import { ALL_WORK, buildBoard, type BoardFilter } from "./board";
21
+ import { BoardView } from "./board-view";
22
+ import { authorityLabel, authorityTone, errorLabel, relativeAge, toneColor } from "./format";
23
+ import { OverviewView } from "./overview-view";
24
+ import { buildProject, isParked, workIn, type ProjectModel } from "./project";
25
+ import {
26
+ AlertRow,
27
+ BlockerRow,
28
+ Empty,
29
+ IssueDetailView,
30
+ SearchResultRow,
31
+ SectionHeader,
32
+ facetContext,
33
+ TrackBlock,
34
+ WorkRow,
35
+ } from "./rows";
36
+ import { createPanelStyles, type PanelStyles } from "./styles";
37
+
38
+ const DASHBOARD_STALE_MS = 10_000;
39
+
40
+ /** Currently inspected issue id, or nothing selected. */
41
+ type Selection = string | null;
42
+
43
+ /** Submitted search text, or nothing submitted yet. */
44
+ type SubmittedQuery = string | null;
45
+
46
+ /** Mutually exclusive operational modes of the master pane. */
47
+ type ViewMode = "overview" | "board" | "plan" | "risks";
48
+
49
+ /** The list-shaped views that share one renderer. */
50
+ type ListMode = "plan" | "risks";
51
+
52
+ interface ViewSpec {
53
+ readonly mode: ViewMode;
54
+ readonly label: string;
55
+ /** `null` when the `bv` section backing this view is unavailable; `undefined` shows no count. */
56
+ readonly count?: number | null;
57
+ }
58
+
59
+ export function BeadsPanel(props: PluginWorkspacePanelProps) {
60
+ return <BeadsWorkspacePanel key={props.workspaceId} {...props} />;
61
+ }
62
+
63
+ function BeadsWorkspacePanel({ theme, layout, workspaceId }: PluginWorkspacePanelProps) {
64
+ const styles = useMemo(() => createPanelStyles(theme, layout.compact), [theme, layout.compact]);
65
+ const workspaceName = useWorkspace(workspaceId, (workspace) => workspace.name);
66
+ const forceDashboardRefresh = useRef(false);
67
+ const listScrollRef = useRef<ScrollView | null>(null);
68
+ const listScrollOffset = useRef(0);
69
+ const pendingListRestore = useRef(false);
70
+
71
+ const fetchDashboard = useRpc(dashboardRpc);
72
+ const fetchSearch = useRpc(searchRpc);
73
+ const fetchIssue = useRpc(issueRpc);
74
+
75
+ const [selectedId, setSelectedId] = useState<Selection>(null);
76
+ const [queryText, setQueryText] = useState("");
77
+ const [submittedQuery, setSubmittedQuery] = useState<SubmittedQuery>(null);
78
+ const [viewMode, setViewMode] = useState<ViewMode>("overview");
79
+ // The board's own controls: what it is narrowed to, and whether finished work
80
+ // is shown. Done work is hidden by default because a mature project buries
81
+ // its live issues under closed ones.
82
+ const [boardFilter, setBoardFilter] = useState<BoardFilter>(ALL_WORK);
83
+ const [showDone, setShowDone] = useState(false);
84
+
85
+ // Slash commands and Command Center actions may target the panel before it mounts.
86
+ const focusRevision = useSyncExternalStore(subscribeIssueFocus, issueFocusRevision, issueFocusRevision);
87
+ const refreshRevision = useSyncExternalStore(
88
+ subscribeIssueFocus,
89
+ dashboardRefreshRevision,
90
+ dashboardRefreshRevision,
91
+ );
92
+ useEffect(() => {
93
+ const requested = takeIssueFocus(workspaceId);
94
+ if (requested !== null) setSelectedId(requested);
95
+ }, [workspaceId, focusRevision]);
96
+
97
+ const dashboard = useQuery({
98
+ queryKey: ["beads", "dashboard", workspaceId],
99
+ queryFn: () => {
100
+ const refresh = forceDashboardRefresh.current;
101
+ forceDashboardRefresh.current = false;
102
+ return fetchDashboard({ workspaceId, refresh });
103
+ },
104
+ staleTime: DASHBOARD_STALE_MS,
105
+ });
106
+
107
+ useEffect(() => {
108
+ if (!takeDashboardRefresh(workspaceId)) return;
109
+ forceDashboardRefresh.current = true;
110
+ void dashboard.refetch({ cancelRefetch: true });
111
+ }, [workspaceId, refreshRevision, dashboard.refetch]);
112
+
113
+ const search = useQuery({
114
+ queryKey: ["beads", "search", workspaceId, submittedQuery],
115
+ queryFn: () => fetchSearch({ workspaceId, query: submittedQuery ?? "", limit: SEARCH_LIMIT_DEFAULT }),
116
+ enabled: submittedQuery !== null && submittedQuery.length > 0,
117
+ });
118
+
119
+ const issue = useQuery({
120
+ queryKey: ["beads", "issue", workspaceId, selectedId],
121
+ queryFn: () => fetchIssue({ workspaceId, issueId: selectedId ?? "" }),
122
+ enabled: selectedId !== null && selectedId.length > 0,
123
+ });
124
+
125
+ const refresh = useCallback(() => {
126
+ // One refetch owns the query key, so React Query cannot deduplicate away the
127
+ // server-side cache bypass.
128
+ forceDashboardRefresh.current = true;
129
+ void dashboard.refetch({ cancelRefetch: true });
130
+ }, [dashboard]);
131
+
132
+ const exitSearch = useCallback(() => {
133
+ setSubmittedQuery(null);
134
+ setQueryText("");
135
+ }, []);
136
+
137
+ const submitSearch = useCallback(() => {
138
+ const trimmed = queryText.trim();
139
+ if (trimmed.length === 0) {
140
+ exitSearch();
141
+ return;
142
+ }
143
+ // Re-submitting the same query must still re-read bv; setting identical
144
+ // state would otherwise leave the Search button as a no-op.
145
+ if (trimmed === submittedQuery) {
146
+ void search.refetch({ cancelRefetch: false });
147
+ return;
148
+ }
149
+ setSubmittedQuery(trimmed);
150
+ }, [exitSearch, queryText, search.refetch, submittedQuery]);
151
+
152
+ const clearSelection = useCallback(() => {
153
+ if (layout.compact && listScrollOffset.current > 0) pendingListRestore.current = true;
154
+ setSelectedId(null);
155
+ }, [layout.compact]);
156
+
157
+ const listIdentity = submittedQuery === null ? `view:${viewMode}` : `search:${submittedQuery}`;
158
+ useEffect(() => {
159
+ // A different operational view or query is a different list and starts at
160
+ // the top. Keeping this ref in sync also prevents compact Back from
161
+ // restoring an offset captured from the previous list.
162
+ listScrollOffset.current = 0;
163
+ pendingListRestore.current = false;
164
+ }, [listIdentity]);
165
+
166
+ const data = dashboard.data ?? null;
167
+ const authority = data?.source?.authority ?? null;
168
+ const railTone = data === null ? "neutral" : data.tool.available ? authorityTone(authority) : "danger";
169
+
170
+ // Derived above every early return so hook order stays stable across states.
171
+ const project = useMemo(() => projectFor(data), [data]);
172
+ const boardModel = useMemo(
173
+ () => buildBoard(project, boardFilter, showDone),
174
+ [project, boardFilter, showDone],
175
+ );
176
+ const boardActive = submittedQuery === null && viewMode === "board";
177
+
178
+ const views: readonly ViewSpec[] = useMemo(() => viewSpecs(data, project), [data, project]);
179
+ const activeViewLabel = views.find((view) => view.mode === viewMode)?.label ?? "Overview";
180
+ const searchActive = submittedQuery !== null;
181
+
182
+ // One status line: where the project stands, then how fresh that is. Source
183
+ // provenance is for diagnosing the tool, so it appears only when the source
184
+ // is not healthy; the rail colour carries it otherwise.
185
+ const statusLine = useMemo(() => {
186
+ if (dashboard.isPending) return "Reading bv analysis…";
187
+ if (data === null) return workspaceName ?? workspaceId;
188
+ const authority = data.source?.authority ?? null;
189
+ const counts = data.counts;
190
+ const figures = project.complete
191
+ ? [
192
+ `${project.counts.done}/${project.work.length} done`,
193
+ `${project.counts.ready} ready`,
194
+ `${project.counts.waiting} waiting`,
195
+ `${project.counts.active} in progress`,
196
+ project.counts.held === 0 ? null : `${project.counts.held} held`,
197
+ ]
198
+ : counts === null
199
+ ? []
200
+ : [
201
+ `${counts.open} open`,
202
+ `${counts.inProgress} in progress`,
203
+ `${counts.actionable} actionable`,
204
+ counts.waiting === null ? null : `${counts.waiting} waiting`,
205
+ ];
206
+ const age = relativeAge(data.source?.generatedAt ?? null);
207
+ return [
208
+ workspaceName ?? workspaceId,
209
+ ...figures,
210
+ project.truncated ? "some closed issues not loaded" : null,
211
+ age === null ? null : `read ${age}${data.cached ? " (cached)" : ""}`,
212
+ authorityTone(authority) === "success" ? null : `source ${authorityLabel(authority)}`,
213
+ ]
214
+ .filter((part): part is string => part !== null && part.length > 0)
215
+ .join(" · ");
216
+ }, [dashboard.isPending, data, project, workspaceName, workspaceId]);
217
+
218
+ const refreshButton = (
219
+ <Pressable
220
+ accessibilityRole="button"
221
+ accessibilityLabel="Refresh Beads analysis"
222
+ accessibilityState={{ busy: dashboard.isFetching }}
223
+ onPress={refresh}
224
+ style={({ pressed }) => [styles.action, pressed ? styles.actionPressed : null]}
225
+ >
226
+ <Text style={styles.actionText}>{dashboard.isFetching ? "Reading…" : "Refresh"}</Text>
227
+ </Pressable>
228
+ );
229
+
230
+ /** The panel's only chrome: one control row over one status line. */
231
+ const header = (
232
+ <View style={styles.topBar}>
233
+ <View style={styles.toolbarRow}>
234
+ <View style={[styles.rail, { backgroundColor: toneColor(theme, railTone) }]} />
235
+ <ViewSwitcher styles={styles} views={views} viewMode={viewMode} onSelect={setViewMode} />
236
+ <View style={styles.searchRow}>
237
+ <TextInput
238
+ accessibilityLabel="Search Beads issues"
239
+ placeholder="Search issues"
240
+ placeholderTextColor={theme.colors.foregroundMuted}
241
+ value={queryText}
242
+ onChangeText={setQueryText}
243
+ onSubmitEditing={submitSearch}
244
+ maxLength={SEARCH_QUERY_MAX_LENGTH}
245
+ returnKeyType="search"
246
+ style={styles.input}
247
+ />
248
+ <Pressable
249
+ accessibilityRole="button"
250
+ accessibilityLabel="Run Beads search"
251
+ onPress={submitSearch}
252
+ style={({ pressed }) => [styles.action, pressed ? styles.actionPressed : null]}
253
+ >
254
+ <Text style={styles.actionText}>Search</Text>
255
+ </Pressable>
256
+ </View>
257
+ {refreshButton}
258
+ </View>
259
+ <Text style={styles.subtitle} numberOfLines={layout.compact ? 2 : 1}>
260
+ {statusLine}
261
+ </Text>
262
+ </View>
263
+ );
264
+
265
+ const requestFailure = dashboard.isError ? (
266
+ <View style={styles.banner}>
267
+ <Text style={styles.danger} accessibilityLabel="Beads analysis failed">
268
+ The Beads analysis request failed.{" "}
269
+ {dashboard.error instanceof Error ? dashboard.error.message : "Unknown error."}
270
+ </Text>
271
+ </View>
272
+ ) : null;
273
+
274
+ // Compact drill-in: the inspector fully replaces the dashboard screen.
275
+ if (layout.compact && selectedId !== null) {
276
+ return (
277
+ <View style={styles.screen}>
278
+ <View style={styles.topBar}>
279
+ <View style={styles.backRow}>
280
+ <Pressable
281
+ accessibilityRole="button"
282
+ accessibilityLabel="Back to the Beads dashboard"
283
+ onPress={clearSelection}
284
+ style={({ pressed }) => [styles.action, pressed ? styles.actionPressed : null]}
285
+ >
286
+ <Text style={styles.actionText}>← Back</Text>
287
+ </Pressable>
288
+ <View style={styles.headerText}>
289
+ <Text style={styles.title}>Issue detail</Text>
290
+ <Text style={styles.subtitle}>{selectedId}</Text>
291
+ </View>
292
+ </View>
293
+ </View>
294
+ <ScrollView key={selectedId} style={styles.paneScroll} contentContainerStyle={styles.detailContent}>
295
+ <IssueInspectorBody styles={styles} theme={theme} issue={issue} />
296
+ </ScrollView>
297
+ </View>
298
+ );
299
+ }
300
+
301
+ const notice = data === null ? null : noticeFor(data);
302
+ if (data === null || notice !== null) {
303
+ return (
304
+ <View style={styles.screen}>
305
+ {header}
306
+ {requestFailure}
307
+ <ScrollView style={styles.paneScroll} contentContainerStyle={styles.noticeContent}>
308
+ {notice === null ? (
309
+ dashboard.isPending ? <Empty styles={styles} theme={theme} message="Loading…" /> : null
310
+ ) : (
311
+ <View style={styles.stateBlock}>
312
+ <Text
313
+ style={notice.tone === "danger" ? styles.danger : styles.body}
314
+ accessibilityLabel={notice.accessibilityLabel}
315
+ >
316
+ {notice.headline}
317
+ </Text>
318
+ <Text style={styles.muted}>{notice.detail}</Text>
319
+ </View>
320
+ )}
321
+ {selectedId === null ? null : (
322
+ <View style={styles.stateBlock}>
323
+ <SectionHeader styles={styles} theme={theme} title="Issue detail" meta={selectedId} />
324
+ <IssueInspectorBody styles={styles} theme={theme} issue={issue} />
325
+ <Pressable
326
+ accessibilityRole="button"
327
+ accessibilityLabel="Clear the selected issue"
328
+ onPress={clearSelection}
329
+ style={({ pressed }) => [styles.action, styles.actionInline, pressed ? styles.actionPressed : null]}
330
+ >
331
+ <Text style={styles.actionText}>Clear selection</Text>
332
+ </Pressable>
333
+ </View>
334
+ )}
335
+ </ScrollView>
336
+ </View>
337
+ );
338
+ }
339
+
340
+ const searchNotice = searchActive ? (
341
+ <View style={styles.backRow}>
342
+ <Pressable
343
+ accessibilityRole="button"
344
+ accessibilityLabel={`Leave search results and return to ${activeViewLabel}`}
345
+ onPress={exitSearch}
346
+ style={({ pressed }) => [styles.action, pressed ? styles.actionPressed : null]}
347
+ >
348
+ <Text style={styles.actionText}>← {activeViewLabel}</Text>
349
+ </Pressable>
350
+ <Text style={styles.muted}>Search results for “{submittedQuery}”</Text>
351
+ </View>
352
+ ) : null;
353
+
354
+ const board = (
355
+ <BoardView
356
+ styles={styles}
357
+ theme={theme}
358
+ project={project}
359
+ board={boardModel}
360
+ compact={layout.compact}
361
+ selectedId={selectedId}
362
+ onSelect={setSelectedId}
363
+ onFilterChange={setBoardFilter}
364
+ onShowDoneChange={setShowDone}
365
+ />
366
+ );
367
+
368
+ const masterList = searchActive ? (
369
+ <SearchList
370
+ styles={styles}
371
+ theme={theme}
372
+ search={search}
373
+ submittedQuery={submittedQuery}
374
+ selectedId={selectedId}
375
+ onSelect={setSelectedId}
376
+ />
377
+ ) : viewMode === "board" ? null : viewMode === "overview" ? (
378
+ <OverviewView
379
+ styles={styles}
380
+ theme={theme}
381
+ project={project}
382
+ health={data.health}
383
+ recommendations={data.sections.triage.status === "ok" ? data.recommendations : []}
384
+ selectedId={selectedId}
385
+ onSelect={setSelectedId}
386
+ />
387
+ ) : (
388
+ <ListView
389
+ styles={styles}
390
+ theme={theme}
391
+ data={data}
392
+ project={project}
393
+ viewMode={viewMode}
394
+ selectedId={selectedId}
395
+ onSelect={setSelectedId}
396
+ />
397
+ );
398
+
399
+ if (layout.compact) {
400
+ return (
401
+ <View style={styles.screen}>
402
+ {header}
403
+ {requestFailure}
404
+ <ScrollView
405
+ key={listIdentity}
406
+ ref={listScrollRef}
407
+ style={styles.paneScroll}
408
+ contentContainerStyle={styles.paneContent}
409
+ onScroll={(event) => {
410
+ listScrollOffset.current = event.nativeEvent.contentOffset.y;
411
+ }}
412
+ onContentSizeChange={() => {
413
+ if (!pendingListRestore.current) return;
414
+ listScrollRef.current?.scrollTo({ y: listScrollOffset.current, animated: false });
415
+ pendingListRestore.current = false;
416
+ }}
417
+ scrollEventThrottle={16}
418
+ >
419
+ {searchNotice}
420
+ {boardActive ? board : masterList}
421
+ </ScrollView>
422
+ </View>
423
+ );
424
+ }
425
+
426
+ return (
427
+ <View style={styles.screen}>
428
+ {header}
429
+ {requestFailure}
430
+ <View style={styles.workbench}>
431
+ {/* Board view needs the width; operational views favour the working list 5:4. */}
432
+ <View
433
+ style={[
434
+ styles.masterPane,
435
+ boardActive ? styles.masterPaneWide : null,
436
+ selectedId === null ? styles.masterPaneAlone : null,
437
+ ]}
438
+ >
439
+ {boardActive ? (
440
+ board
441
+ ) : (
442
+ <ScrollView
443
+ key={listIdentity}
444
+ style={styles.paneScroll}
445
+ contentContainerStyle={styles.paneContent}
446
+ >
447
+ {searchNotice}
448
+ {masterList}
449
+ </ScrollView>
450
+ )}
451
+ </View>
452
+ {/* An empty inspector is a third of the panel spent on a sentence. The
453
+ pane appears when there is an issue to read and gives the width back
454
+ when there is not. */}
455
+ {selectedId === null ? null : (
456
+ <View style={[styles.detailPane, boardActive ? styles.detailPaneNarrow : null]}>
457
+ <ScrollView key={selectedId} style={styles.paneScroll} contentContainerStyle={styles.detailContent}>
458
+ <>
459
+ <View style={styles.backRow}>
460
+ <View style={styles.headerText}>
461
+ <SectionHeader styles={styles} theme={theme} title="Issue detail" meta={selectedId} />
462
+ </View>
463
+ <Pressable
464
+ accessibilityRole="button"
465
+ accessibilityLabel="Clear the selected issue"
466
+ onPress={clearSelection}
467
+ style={({ pressed }) => [
468
+ styles.action,
469
+ styles.actionInline,
470
+ pressed ? styles.actionPressed : null,
471
+ ]}
472
+ >
473
+ <Text style={styles.actionText}>Clear</Text>
474
+ </Pressable>
475
+ </View>
476
+ <IssueInspectorBody styles={styles} theme={theme} issue={issue} />
477
+ </>
478
+ </ScrollView>
479
+ </View>
480
+ )}
481
+ </View>
482
+ </View>
483
+ );
484
+ }
485
+
486
+ /** A whole-panel state that replaces the workbench: no analysis to lay out. */
487
+ interface Notice {
488
+ readonly tone: "danger" | "neutral";
489
+ readonly headline: string;
490
+ readonly detail: string;
491
+ readonly accessibilityLabel: string;
492
+ }
493
+
494
+ function noticeFor(data: DashboardResult): Notice | null {
495
+ if (!data.tool.available) {
496
+ return {
497
+ tone: "danger",
498
+ headline: errorLabel(data.tool.error),
499
+ detail:
500
+ (data.tool.error?.code === "unavailable"
501
+ ? "Install the bv CLI on the daemon machine and refresh."
502
+ : "Resolve the workspace path or connection error above, then refresh.") +
503
+ " This panel only runs read-only commands.",
504
+ accessibilityLabel: "bv is unavailable",
505
+ };
506
+ }
507
+ if (data.projectState === "missing") {
508
+ return {
509
+ tone: "neutral",
510
+ headline: "No Beads project in this workspace.",
511
+ detail:
512
+ "bv found no .beads source here. Initialise Beads with br or bd in this directory, then refresh.",
513
+ accessibilityLabel: "No Beads project in this workspace",
514
+ };
515
+ }
516
+ if (data.projectState === "error") {
517
+ return {
518
+ tone: "danger",
519
+ headline: errorLabel(data.sections.triage.error),
520
+ detail: "The Beads source could not be analysed. Nothing was written; retry after fixing the source.",
521
+ accessibilityLabel: "The Beads analysis could not be read",
522
+ };
523
+ }
524
+ return null;
525
+ }
526
+
527
+ function viewSpecs(data: DashboardResult | null, project: ProjectModel): readonly ViewSpec[] {
528
+ if (data === null) {
529
+ return [
530
+ { mode: "overview", label: "Overview" },
531
+ { mode: "board", label: "Board", count: null },
532
+ { mode: "plan", label: "Plan", count: null },
533
+ { mode: "risks", label: "Risks", count: null },
534
+ ];
535
+ }
536
+ const graphOk = data.sections.graph.status === "ok";
537
+ const planOk = data.sections.plan.status === "ok";
538
+ const risks = riskCount(data, project);
539
+ return [
540
+ { mode: "overview", label: "Overview" },
541
+ {
542
+ mode: "board",
543
+ label: "Board",
544
+ // Live work on the board; null only when no source at all could be read.
545
+ count:
546
+ !graphOk && data.sections.triage.status !== "ok" && !planOk
547
+ ? null
548
+ : project.work.length - project.counts.done,
549
+ },
550
+ { mode: "plan", label: "Plan", count: planOk ? planTracks(data, project).length : null },
551
+ { mode: "risks", label: "Risks", count: risks },
552
+ ];
553
+ }
554
+
555
+ /**
556
+ * What the Risks badge counts: stuck work (held, but not deliberately parked),
557
+ * a dependency cycle, and alerts `bv` rated critical or warning. Informational
558
+ * alerts, parked work and keystones are listed but not counted, so the badge is
559
+ * zero when nothing needs a decision. It is unknown, not zero, unless every
560
+ * source it counts was read.
561
+ */
562
+ function riskCount(data: DashboardResult, project: ProjectModel): number | null {
563
+ if (!risksKnown(data, project)) return null;
564
+ const serious = data.alerts.filter((alert) => isSerious(alert.severity)).length;
565
+ const stuck = workIn(project, "held").filter((item) => !isParked(item.status)).length;
566
+ return stuck + (hasCycle(data, project) ? 1 : 0) + serious;
567
+ }
568
+
569
+ /** True when the alerts, the cycle check and the whole graph were all read. */
570
+ function risksKnown(data: DashboardResult, project: ProjectModel): boolean {
571
+ return data.sections.alerts.status === "ok" && data.health?.hasCycles != null && project.complete;
572
+ }
573
+
574
+ function hasCycle(data: DashboardResult, project: ProjectModel): boolean {
575
+ return data.health?.hasCycles === true || project.chainCycle;
576
+ }
577
+
578
+ function isSerious(severity: string): boolean {
579
+ const normalized = severity.trim().toLowerCase();
580
+ return normalized === "critical" || normalized === "warning";
581
+ }
582
+
583
+ /**
584
+ * Plan tracks with their containers removed: `bv` lists an epic as actionable
585
+ * when nothing blocks it, but nobody works on an epic directly.
586
+ */
587
+ function planTracks(data: DashboardResult, project: ProjectModel): DashboardResult["tracks"] {
588
+ return data.tracks
589
+ .map((track) => ({ ...track, items: track.items.filter((item) => project.byId.get(item.id)?.container !== true) }))
590
+ .filter((track) => track.items.length > 0);
591
+ }
592
+
593
+ const EMPTY_PROJECT_INPUT = {
594
+ graphAvailable: false,
595
+ issues: [],
596
+ truncated: false,
597
+ recommendations: [],
598
+ tracks: [],
599
+ } as const;
600
+
601
+ /**
602
+ * The project is the whole issue graph; triage and plan only add score, action
603
+ * and track membership. A degraded section contributes nothing rather than an
604
+ * invented state.
605
+ */
606
+ function projectFor(data: DashboardResult | null): ProjectModel {
607
+ if (data === null) return buildProject(EMPTY_PROJECT_INPUT);
608
+ const graphOk = data.sections.graph.status === "ok";
609
+ return buildProject({
610
+ graphAvailable: graphOk,
611
+ issues: graphOk ? data.board.issues : [],
612
+ truncated: graphOk && data.board.truncated,
613
+ recommendations: data.sections.triage.status === "ok" ? data.recommendations : [],
614
+ tracks: data.sections.plan.status === "ok" ? data.tracks : [],
615
+ });
616
+ }
617
+
618
+ function ViewSwitcher({
619
+ styles,
620
+ views,
621
+ viewMode,
622
+ onSelect,
623
+ }: {
624
+ styles: PanelStyles;
625
+ views: readonly ViewSpec[];
626
+ viewMode: ViewMode;
627
+ onSelect: (mode: ViewMode) => void;
628
+ }) {
629
+ return (
630
+ <View style={styles.switcherRow} accessibilityRole="tablist" accessibilityLabel="Beads operational views">
631
+ {views.map((view) => {
632
+ const selected = view.mode === viewMode;
633
+ return (
634
+ <Pressable
635
+ key={view.mode}
636
+ accessibilityRole="tab"
637
+ accessibilityLabel={
638
+ view.count === undefined
639
+ ? `${view.label} view`
640
+ : view.count === null
641
+ ? `${view.label} view, unavailable`
642
+ : `${view.label} view, ${view.count} item${view.count === 1 ? "" : "s"}`
643
+ }
644
+ accessibilityState={{ selected }}
645
+ onPress={() => onSelect(view.mode)}
646
+ style={({ pressed }) => [
647
+ styles.switcherItem,
648
+ selected ? styles.switcherItemSelected : null,
649
+ pressed && !selected ? styles.railRowSelected : null,
650
+ ]}
651
+ >
652
+ <Text style={[styles.switcherLabel, selected ? styles.switcherLabelSelected : null]}>
653
+ {view.label}
654
+ </Text>
655
+ {view.count === undefined ? null : (
656
+ <Text style={styles.switcherCount}>{view.count === null ? "—" : view.count}</Text>
657
+ )}
658
+ </Pressable>
659
+ );
660
+ })}
661
+ </View>
662
+ );
663
+ }
664
+
665
+ function ListView({
666
+ styles,
667
+ theme,
668
+ data,
669
+ project,
670
+ viewMode,
671
+ selectedId,
672
+ onSelect,
673
+ }: {
674
+ styles: PanelStyles;
675
+ theme: PluginWorkspacePanelProps["theme"];
676
+ data: DashboardResult;
677
+ project: ProjectModel;
678
+ viewMode: ListMode;
679
+ selectedId: Selection;
680
+ onSelect: (issueId: string) => void;
681
+ }) {
682
+ if (viewMode === "plan") {
683
+ const tracks = planTracks(data, project);
684
+ // bv's own count, since the payload caps how many tracks it carries.
685
+ const trackTotal = Math.max(data.planSummary?.totalTracks ?? 0, tracks.length);
686
+ return (
687
+ <View style={styles.listGroup}>
688
+ <SectionHeader
689
+ styles={styles}
690
+ theme={theme}
691
+ title="Execution tracks"
692
+ meta={
693
+ data.sections.plan.status !== "ok"
694
+ ? "unavailable"
695
+ : trackTotal > data.tracks.length
696
+ ? `${trackTotal} parallel · ${tracks.length} shown`
697
+ : `${tracks.length} parallel`
698
+ }
699
+ />
700
+ {data.sections.plan.status !== "ok" ? (
701
+ <Text style={styles.danger}>{errorLabel(data.sections.plan.error)}</Text>
702
+ ) : tracks.length === 0 ? (
703
+ <Empty styles={styles} theme={theme} message="bv found no work that can start, so there is no track." />
704
+ ) : (
705
+ <>
706
+ <Text style={styles.muted}>
707
+ {trackTotal === 1
708
+ ? "One track: everything that can start now shares its dependencies, so a second agent would contend with the first."
709
+ : `${trackTotal} independent tracks: work in different tracks shares no dependency, so agents can take one each.`}
710
+ </Text>
711
+ {tracks.map((track) => (
712
+ <TrackBlock
713
+ key={track.id}
714
+ styles={styles}
715
+ theme={theme}
716
+ track={track}
717
+ selectedId={selectedId}
718
+ onSelect={onSelect}
719
+ />
720
+ ))}
721
+ </>
722
+ )}
723
+ </View>
724
+ );
725
+ }
726
+
727
+ return <RisksView styles={styles} theme={theme} data={data} project={project} selectedId={selectedId} onSelect={onSelect} />;
728
+ }
729
+
730
+ /**
731
+ * Risks lead with what needs a decision — held work, a cycle, serious alerts —
732
+ * and keep informational alerts folded, so a heuristic like "potential
733
+ * duplicate" cannot drown the real signal or inflate the badge.
734
+ */
735
+ function RisksView({
736
+ styles,
737
+ theme,
738
+ data,
739
+ project,
740
+ selectedId,
741
+ onSelect,
742
+ }: {
743
+ styles: PanelStyles;
744
+ theme: PluginWorkspacePanelProps["theme"];
745
+ data: DashboardResult;
746
+ project: ProjectModel;
747
+ selectedId: Selection;
748
+ onSelect: (issueId: string) => void;
749
+ }) {
750
+ const [showInfo, setShowInfo] = useState(false);
751
+ const held = workIn(project, "held");
752
+ const stuck = held.filter((item) => !isParked(item.status));
753
+ const alertsOk = data.sections.alerts.status === "ok";
754
+ const serious = alertsOk ? data.alerts.filter((alert) => isSerious(alert.severity)) : [];
755
+ const info = alertsOk ? data.alerts.filter((alert) => !isSerious(alert.severity)) : [];
756
+ const cycles = hasCycle(data, project);
757
+ // An all-clear is only claimed when every source behind it was read.
758
+ const known = risksKnown(data, project);
759
+ const nothing = known && stuck.length === 0 && serious.length === 0 && !cycles;
760
+
761
+ return (
762
+ <View style={styles.listGroup}>
763
+ {nothing ? (
764
+ <Text style={styles.body}>
765
+ Nothing needs a decision: no stuck work, no dependency cycle, no serious alert.
766
+ {held.length === 0 ? "" : ` ${held.length} parked issue${held.length === 1 ? " is" : "s are"} listed below.`}
767
+ </Text>
768
+ ) : null}
769
+ {known ? null : (
770
+ <Text style={styles.muted}>
771
+ Some sources could not be read, so this list may be incomplete: {[
772
+ data.sections.alerts.status === "ok" ? null : "alerts",
773
+ data.health?.hasCycles != null ? null : "cycle check",
774
+ project.complete ? null : "whole-project graph",
775
+ ]
776
+ .filter((part): part is string => part !== null)
777
+ .join(", ")}
778
+ .
779
+ </Text>
780
+ )}
781
+ {cycles ? (
782
+ <Text style={styles.danger}>
783
+ bv found a dependency cycle. Work on the cycle can never become ready until one dependency is removed.
784
+ </Text>
785
+ ) : null}
786
+ {held.length === 0 ? null : (
787
+ <>
788
+ <SectionHeader styles={styles} theme={theme} title="Held" meta={`${held.length}`} />
789
+ {held.map((item) => (
790
+ <WorkRow
791
+ key={item.id}
792
+ styles={styles}
793
+ theme={theme}
794
+ item={item}
795
+ showState
796
+ context={facetContext(project)}
797
+ selected={selectedId === item.id}
798
+ onSelect={onSelect}
799
+ />
800
+ ))}
801
+ </>
802
+ )}
803
+
804
+ <SectionHeader
805
+ styles={styles}
806
+ theme={theme}
807
+ title="Alerts"
808
+ meta={alertsOk ? `${serious.length} serious · ${info.length} informational` : "unavailable"}
809
+ />
810
+ {!alertsOk ? (
811
+ <Text style={styles.danger}>{errorLabel(data.sections.alerts.error)}</Text>
812
+ ) : serious.length === 0 && info.length === 0 ? (
813
+ <Empty styles={styles} theme={theme} message="No alert is open." />
814
+ ) : (
815
+ <>
816
+ {serious.map((alert, index) => (
817
+ <AlertRow
818
+ key={`${alert.type}:${alert.issueId ?? index}`}
819
+ styles={styles}
820
+ theme={theme}
821
+ alert={alert}
822
+ selectedId={selectedId}
823
+ onSelect={onSelect}
824
+ />
825
+ ))}
826
+ {info.length === 0 ? null : (
827
+ <Pressable
828
+ accessibilityRole="button"
829
+ accessibilityState={{ expanded: showInfo }}
830
+ accessibilityLabel={`${showInfo ? "Hide" : "Show"} ${info.length} informational alerts`}
831
+ onPress={() => setShowInfo((current) => !current)}
832
+ style={({ pressed }) => [styles.action, styles.actionInline, pressed ? styles.actionPressed : null]}
833
+ >
834
+ <Text style={styles.actionText}>
835
+ {showInfo ? "Hide" : "Show"} {info.length} informational alert{info.length === 1 ? "" : "s"}
836
+ </Text>
837
+ </Pressable>
838
+ )}
839
+ {!showInfo
840
+ ? null
841
+ : info.map((alert, index) => (
842
+ <AlertRow
843
+ key={`${alert.type}:${alert.issueId ?? index}`}
844
+ styles={styles}
845
+ theme={theme}
846
+ alert={alert}
847
+ selectedId={selectedId}
848
+ onSelect={onSelect}
849
+ />
850
+ ))}
851
+ </>
852
+ )}
853
+
854
+ <SectionHeader
855
+ styles={styles}
856
+ theme={theme}
857
+ title="Keystones"
858
+ meta={data.sections.triage.status === "ok" ? `${data.blockers.length}` : "unavailable"}
859
+ />
860
+ {data.sections.triage.status !== "ok" ? (
861
+ <Text style={styles.danger}>{errorLabel(data.sections.triage.error)}</Text>
862
+ ) : data.blockers.length === 0 ? (
863
+ <Empty styles={styles} theme={theme} message="Nothing is holding up downstream work." />
864
+ ) : (
865
+ <>
866
+ <Text style={styles.muted}>Finishing these unblocks the most downstream work, by bv's analysis.</Text>
867
+ {data.blockers.map((blocker) => (
868
+ <BlockerRow
869
+ key={blocker.id}
870
+ styles={styles}
871
+ theme={theme}
872
+ blocker={blocker}
873
+ selectedId={selectedId}
874
+ onSelect={onSelect}
875
+ />
876
+ ))}
877
+ </>
878
+ )}
879
+ </View>
880
+ );
881
+ }
882
+
883
+ /** The slice of a React Query result the presentational bodies actually read. */
884
+ interface QueryState<TData> {
885
+ readonly isPending: boolean;
886
+ readonly data: TData | undefined;
887
+ }
888
+
889
+ type SearchQueryState = QueryState<{
890
+ readonly query: string;
891
+ readonly results: readonly SearchResult[];
892
+ readonly error: CommandError | null;
893
+ }>;
894
+
895
+ type IssueQueryState = QueryState<{
896
+ readonly issue: IssueDetail | null;
897
+ readonly error: CommandError | null;
898
+ }>;
899
+
900
+ function SearchList({
901
+ styles,
902
+ theme,
903
+ search,
904
+ submittedQuery,
905
+ selectedId,
906
+ onSelect,
907
+ }: {
908
+ styles: PanelStyles;
909
+ theme: PluginWorkspacePanelProps["theme"];
910
+ search: SearchQueryState;
911
+ submittedQuery: SubmittedQuery;
912
+ selectedId: Selection;
913
+ onSelect: (issueId: string) => void;
914
+ }) {
915
+ return (
916
+ <View style={styles.listGroup}>
917
+ <SectionHeader
918
+ styles={styles}
919
+ theme={theme}
920
+ title="Search results"
921
+ meta={search.data?.error === null ? `${search.data.results.length} found` : null}
922
+ />
923
+ {submittedQuery === null ? null : search.isPending ? (
924
+ <Empty styles={styles} theme={theme} message="Searching…" />
925
+ ) : search.data === undefined ? (
926
+ <Text style={styles.danger}>The search request failed.</Text>
927
+ ) : search.data.error !== null ? (
928
+ <Text style={styles.danger}>{errorLabel(search.data.error)}</Text>
929
+ ) : search.data.results.length === 0 ? (
930
+ <Empty styles={styles} theme={theme} message={`No issue matched “${search.data.query}”.`} />
931
+ ) : (
932
+ search.data.results.map((result) => (
933
+ <SearchResultRow
934
+ key={result.id}
935
+ styles={styles}
936
+ theme={theme}
937
+ result={result}
938
+ selectedId={selectedId}
939
+ onSelect={onSelect}
940
+ />
941
+ ))
942
+ )}
943
+ </View>
944
+ );
945
+ }
946
+
947
+ function IssueInspectorBody({
948
+ styles,
949
+ theme,
950
+ issue,
951
+ }: {
952
+ styles: PanelStyles;
953
+ theme: PluginWorkspacePanelProps["theme"];
954
+ issue: IssueQueryState;
955
+ }) {
956
+ if (issue.isPending) return <Empty styles={styles} theme={theme} message="Reading issue…" />;
957
+ if (issue.data === undefined) return <Text style={styles.danger}>The issue request failed.</Text>;
958
+ if (issue.data.issue === null) return <Text style={styles.danger}>{errorLabel(issue.data.error)}</Text>;
959
+ return <IssueDetailView styles={styles} theme={theme} issue={issue.data.issue} />;
960
+ }
961
+