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.
- package/LICENSE +21 -0
- package/README.md +171 -0
- package/client/board-view.tsx +290 -0
- package/client/board.ts +160 -0
- package/client/focus.ts +51 -0
- package/client/format.ts +227 -0
- package/client/markdown-view.tsx +156 -0
- package/client/markdown.ts +435 -0
- package/client/overview-view.tsx +462 -0
- package/client/panel.tsx +961 -0
- package/client/project.ts +603 -0
- package/client/rows.tsx +666 -0
- package/client/styles.ts +557 -0
- package/images/board.png +0 -0
- package/images/overview.png +0 -0
- package/images/plan.png +0 -0
- package/images/risks.png +0 -0
- package/index.client.tsx +68 -0
- package/index.server.ts +21 -0
- package/package.json +53 -0
- package/paseo-plugin.json +6 -0
- package/server/attachments.ts +166 -0
- package/server/bv.ts +213 -0
- package/server/cache.ts +51 -0
- package/server/command.ts +343 -0
- package/server/dashboard.ts +244 -0
- package/server/issue.ts +59 -0
- package/server/normalize.ts +687 -0
- package/server/search.ts +40 -0
- package/server/tracker.ts +122 -0
- package/server/workspace.ts +153 -0
- package/shared/beads.ts +325 -0
- package/shared/rpc.ts +129 -0
package/client/panel.tsx
ADDED
|
@@ -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
|
+
|