@omercnet/paseo-beads 1.1.0 → 1.2.0-next.214.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.
- package/README.md +36 -4
- package/client/beads-view.ts +104 -1
- package/client/paseo-beads.tsx +53 -7
- package/client/ready-beads.tsx +159 -0
- package/client/ready-scan.ts +78 -0
- package/index.client.tsx +20 -1
- package/package.json +5 -5
- package/paseo-plugin.json +1 -1
- package/server/beads.ts +28 -1
- package/shared/beads.ts +5 -0
package/README.md
CHANGED
|
@@ -4,7 +4,8 @@ A dependency-aware Beads work queue for every Paseo workspace. Paseo Beads turns
|
|
|
4
4
|
a read-only delivery view that makes the ready frontier, work in progress, and blockers immediately
|
|
5
5
|
visible.
|
|
6
6
|
|
|
7
|
-
It adds a workspace-scoped **Beads** Explorer panel and an **Open Beads** Command Center item.
|
|
7
|
+
It adds a workspace-scoped **Beads** Explorer panel and an **Open Beads** Command Center item. On
|
|
8
|
+
Paseo 0.11 and newer it also adds a **Ready beads** sidebar row and a linkable bead screen.
|
|
8
9
|
|
|
9
10
|
## Demo
|
|
10
11
|
|
|
@@ -36,6 +37,10 @@ verified the rendered page contained no private organization names.
|
|
|
36
37
|
layouts.
|
|
37
38
|
- A virtualized issue list, descriptive accessibility labels, and focus restoration when navigating
|
|
38
39
|
between the compact list and detail view.
|
|
40
|
+
- On Paseo 0.11 and newer, a **Ready beads** sidebar row with the number of ready beads across the
|
|
41
|
+
host's workspaces. Pressing it opens a popover (a bottom sheet in compact layouts) with the top 10
|
|
42
|
+
ready beads grouped by workspace. Pressing a bead opens its detail view as a screen whose URL
|
|
43
|
+
carries the workspace and bead IDs, so it can be linked and survives a reload.
|
|
39
44
|
|
|
40
45
|
Within each lane, issues are ordered by priority, most recent update, then ID. In-progress and hooked
|
|
41
46
|
issues enter **In progress** first; remaining blocked issues enter **Blocked**; ready issues enter
|
|
@@ -47,7 +52,10 @@ The panel calls workspace-scoped Paseo plugin RPC handlers. On the daemon host,
|
|
|
47
52
|
the workspace directory through Paseo and executes the `bd` CLI with `--readonly` and `-C`:
|
|
48
53
|
|
|
49
54
|
- The list handler runs `bd list --json --sort priority`, then runs one authoritative
|
|
50
|
-
`bd list --ready --json --limit 0` query and intersects its IDs with the displayed issues.
|
|
55
|
+
`bd list --ready --json --limit 0` query and intersects its IDs with the displayed issues. It also
|
|
56
|
+
runs `bd where --json` and returns a digest of the database location as an opaque `databaseId`,
|
|
57
|
+
so workspaces that share one database, such as git worktrees, can be recognized. This is best
|
|
58
|
+
effort: when `bd` cannot say, the snapshot is the same without it.
|
|
51
59
|
- Selecting an issue runs `bd show <issue-id> --json --include-dependents` plus the same authoritative
|
|
52
60
|
readiness query.
|
|
53
61
|
|
|
@@ -58,9 +66,23 @@ The list and selected detail poll every 10 seconds. **Refresh** requests the lis
|
|
|
58
66
|
failed background refresh keeps the previous data visible with an inline error; initial and detail
|
|
59
67
|
failures provide a retry action.
|
|
60
68
|
|
|
69
|
+
The **Ready beads** row lists the host's workspaces through the Paseo SDK and calls the same list
|
|
70
|
+
handler for them, one workspace at a time, every 2 minutes while the app is in the foreground and
|
|
71
|
+
whenever its popover opens. A snapshot an open panel read in the last 10 seconds is reused instead
|
|
72
|
+
of rerunning `bd`. Workspaces that share a database, such as git worktrees, report the same
|
|
73
|
+
`databaseId`, so an issue counts once however many workspaces read it and is listed under the first
|
|
74
|
+
workspace that reported it. The first scan reads every workspace to learn which ones share a
|
|
75
|
+
database. Later scans read each database once and skip its other workspaces. A workspace is skipped
|
|
76
|
+
only while a successful read in the last 10 minutes vouches that it reads a database the scan has
|
|
77
|
+
already read, so a failed or older read puts it back in the scan. Workspaces without Beads count as
|
|
78
|
+
zero and are rechecked every 5 minutes. When `bd` is unavailable, or a workspace cannot be read,
|
|
79
|
+
the popover says so instead of reporting an empty host, and a failed refresh is shown next to the
|
|
80
|
+
last result. Once nothing shows the count, for example when the host disconnects, a scan that is
|
|
81
|
+
still running starts no further workspace reads.
|
|
82
|
+
|
|
61
83
|
## Limits
|
|
62
84
|
|
|
63
|
-
- Paseo `^0.9.0 || ^0.10.0` with plugins enabled is required.
|
|
85
|
+
- Paseo `^0.9.0 || ^0.10.0 || ^0.11.0` with plugins enabled is required.
|
|
64
86
|
- Beads `bd` 1.0 or newer must be available on the Paseo daemon's `PATH`.
|
|
65
87
|
- A Beads project must be initialized in the workspace for issue data to appear.
|
|
66
88
|
- CLI calls time out after 10 seconds and accept at most 8 MiB of output. Unexpected CLI details stay
|
|
@@ -74,6 +96,15 @@ failures provide a retry action.
|
|
|
74
96
|
project, request failures, and missing issue details.
|
|
75
97
|
- This plugin is read-only and workspace-scoped. It uses the `bd` CLI exclusively and does not
|
|
76
98
|
create, edit, close, or assign issues.
|
|
99
|
+
- The **Ready beads** row and the bead screen need Paseo 0.11. On 0.9 and 0.10 they do not register,
|
|
100
|
+
and the panel and Command Center items work as before.
|
|
101
|
+
- The **Ready beads** count covers the first 200 workspaces the host lists and, like the panel, the
|
|
102
|
+
first 500 issues of each workspace. It refreshes about every two minutes while the app is in the
|
|
103
|
+
foreground, plus the time a scan takes, and when the popover opens, so it can be older after the
|
|
104
|
+
app was in the background. A workspace that moves to another database is noticed within 10
|
|
105
|
+
minutes.
|
|
106
|
+
- A `bd` that cannot report its database (`bd where --json`) leaves every workspace counted on its
|
|
107
|
+
own, so git worktrees of one project are counted separately.
|
|
77
108
|
|
|
78
109
|
## Install
|
|
79
110
|
|
|
@@ -96,7 +127,8 @@ Paseo shows the installed and proposed revisions and asks for approval before ap
|
|
|
96
127
|
update. Review the source changes before approving them.
|
|
97
128
|
|
|
98
129
|
Open a workspace, choose **New tab** in Explorer, then select **Beads**. You can also run **Open
|
|
99
|
-
Beads** from the Command Center while viewing a workspace or one of its agents.
|
|
130
|
+
Beads** from the Command Center while viewing a workspace or one of its agents. On Paseo 0.11 and
|
|
131
|
+
newer, press **Ready beads** in the sidebar for ready work across every workspace on the host.
|
|
100
132
|
|
|
101
133
|
## Develop
|
|
102
134
|
|
package/client/beads-view.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type BeadSummary, type BeadsSnapshot, getWorkspaceBead } from "../shared/beads";
|
|
2
2
|
|
|
3
3
|
export const BEAD_LANES = ["ready", "in_progress", "blocked", "other"] as const;
|
|
4
4
|
|
|
@@ -153,3 +153,106 @@ export function buildBeadsView(
|
|
|
153
153
|
|
|
154
154
|
return { lanes, counts };
|
|
155
155
|
}
|
|
156
|
+
|
|
157
|
+
export const BEAD_SCREEN_ID = "bead";
|
|
158
|
+
|
|
159
|
+
/** Reads bead screen params (`{ workspace, bead }`) with the detail RPC's own input rules. */
|
|
160
|
+
export function parseBeadScreenParams(params: Readonly<Record<string, string>>) {
|
|
161
|
+
const parsed = getWorkspaceBead.input.safeParse({
|
|
162
|
+
workspaceId: params.workspace,
|
|
163
|
+
issueId: params.bead,
|
|
164
|
+
});
|
|
165
|
+
return parsed.success ? parsed.data : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Shared by the panel and the sidebar count, so either one reuses the other's snapshot. */
|
|
169
|
+
export function beadsSnapshotQueryKey(hostId: string, workspaceId: string) {
|
|
170
|
+
return ["paseo-beads", "snapshot", hostId, workspaceId] as const;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface ReadyBeadsWorkspace {
|
|
174
|
+
id: string;
|
|
175
|
+
projectDisplayName: string;
|
|
176
|
+
name: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface ReadyBeadsSummary {
|
|
180
|
+
/** Ready beads across every workspace that loaded. */
|
|
181
|
+
count: number;
|
|
182
|
+
/** The top ready beads, grouped under the workspace each one is listed for. */
|
|
183
|
+
groups: { workspace: ReadyBeadsWorkspace; beads: BeadSummary[] }[];
|
|
184
|
+
/** Workspaces whose snapshot failed to load. */
|
|
185
|
+
failed: number;
|
|
186
|
+
/** Workspaces on a host that cannot run `bd`. */
|
|
187
|
+
unavailable: number;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Workspaces that share a Beads database, such as git worktrees, report the same `databaseId`, so
|
|
192
|
+
* an issue counts once and is listed under the first workspace that reported it. Titles are
|
|
193
|
+
* display data, not identity. A snapshot that cannot name its database counts as having its own.
|
|
194
|
+
*/
|
|
195
|
+
export function summarizeReadyBeads(
|
|
196
|
+
entries: readonly { workspace: ReadyBeadsWorkspace; snapshot: BeadsSnapshot | null }[],
|
|
197
|
+
limit: number,
|
|
198
|
+
): ReadyBeadsSummary {
|
|
199
|
+
const seen = new Set<string>();
|
|
200
|
+
const ready: { workspace: ReadyBeadsWorkspace; bead: BeadSummary }[] = [];
|
|
201
|
+
let failed = 0;
|
|
202
|
+
let unavailable = 0;
|
|
203
|
+
|
|
204
|
+
for (const { workspace, snapshot } of entries) {
|
|
205
|
+
if (!snapshot) failed += 1;
|
|
206
|
+
else if (snapshot.state === "bd_unavailable") unavailable += 1;
|
|
207
|
+
const database = snapshot?.databaseId ?? workspace.id;
|
|
208
|
+
for (const bead of snapshot?.issues ?? []) {
|
|
209
|
+
const key = JSON.stringify([database, bead.id]);
|
|
210
|
+
if (beadLaneFor(bead) !== "ready" || seen.has(key)) continue;
|
|
211
|
+
seen.add(key);
|
|
212
|
+
ready.push({ workspace, bead });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
ready.sort((left, right) => compareBeads(left.bead, right.bead));
|
|
216
|
+
|
|
217
|
+
const groups = new Map<string, ReadyBeadsSummary["groups"][number]>();
|
|
218
|
+
for (const { workspace, bead } of ready.slice(0, limit)) {
|
|
219
|
+
const group = groups.get(workspace.id) ?? { workspace, beads: [] };
|
|
220
|
+
group.beads.push(bead);
|
|
221
|
+
groups.set(workspace.id, group);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { count: ready.length, groups: [...groups.values()], failed, unavailable };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface ReadyBeadsNotes {
|
|
228
|
+
/** Replaces the list when no ready bead was found; qualified when the result is incomplete. */
|
|
229
|
+
empty: string | null;
|
|
230
|
+
/** Caveats about the result, shown below it. */
|
|
231
|
+
notices: string[];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** `refreshError` is the message of a failed refresh that left the previous result in place. */
|
|
235
|
+
export function describeReadyBeads(
|
|
236
|
+
{ count, failed, unavailable }: ReadyBeadsSummary,
|
|
237
|
+
refreshError: string | null,
|
|
238
|
+
): ReadyBeadsNotes {
|
|
239
|
+
const notices: string[] = [];
|
|
240
|
+
if (refreshError) notices.push(`Refresh failed, showing the last result. ${refreshError}`);
|
|
241
|
+
if (failed > 0) {
|
|
242
|
+
notices.push(`${failed === 1 ? "1 workspace" : `${failed} workspaces`} could not be read.`);
|
|
243
|
+
}
|
|
244
|
+
if (unavailable > 0 && count > 0) {
|
|
245
|
+
notices.push(
|
|
246
|
+
`The bd CLI was unavailable for ${unavailable === 1 ? "1 workspace" : `${unavailable} workspaces`}, so the count may be incomplete.`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let empty: string | null = null;
|
|
251
|
+
if (count === 0) {
|
|
252
|
+
if (unavailable > 0) empty = "The bd CLI is not available on this Paseo host.";
|
|
253
|
+
else if (failed > 0 || refreshError) {
|
|
254
|
+
empty = "No ready beads found in the workspaces that could be checked.";
|
|
255
|
+
} else empty = "No ready beads in any workspace on this host.";
|
|
256
|
+
}
|
|
257
|
+
return { empty, notices };
|
|
258
|
+
}
|
package/client/paseo-beads.tsx
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type PluginScreenProps,
|
|
3
|
+
type PluginWorkspacePanelProps,
|
|
4
|
+
useRpc,
|
|
5
|
+
useWorkspace,
|
|
6
|
+
} from "@getpaseo/plugin/client";
|
|
2
7
|
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
3
8
|
import { useQuery } from "@tanstack/react-query";
|
|
4
9
|
import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react";
|
|
@@ -30,16 +35,18 @@ import {
|
|
|
30
35
|
type BeadLane,
|
|
31
36
|
type BeadSection,
|
|
32
37
|
type BeadsFilter,
|
|
38
|
+
beadsSnapshotQueryKey,
|
|
33
39
|
buildBeadSections,
|
|
34
40
|
buildBeadsView,
|
|
35
41
|
issueAccessibilityLabel,
|
|
42
|
+
parseBeadScreenParams,
|
|
36
43
|
} from "./beads-view";
|
|
37
44
|
import { focusWebElement } from "./web";
|
|
38
45
|
|
|
39
46
|
const REFRESH_INTERVAL_MS = 10_000;
|
|
40
47
|
const BACK_BUTTON_NATIVE_ID = "paseo-beads-back";
|
|
41
48
|
const ISSUE_ROW_NATIVE_ID_PREFIX = "paseo-beads-issue-";
|
|
42
|
-
const ID_FONT_FAMILY = Platform.select({ ios: "Menlo", default: "monospace" });
|
|
49
|
+
export const ID_FONT_FAMILY = Platform.select({ ios: "Menlo", default: "monospace" });
|
|
43
50
|
|
|
44
51
|
const FILTERS: readonly { id: BeadsFilter; title: string }[] = [
|
|
45
52
|
{ id: "all", title: "All" },
|
|
@@ -47,7 +54,7 @@ const FILTERS: readonly { id: BeadsFilter; title: string }[] = [
|
|
|
47
54
|
{ id: "assigned", title: "Assigned" },
|
|
48
55
|
];
|
|
49
56
|
|
|
50
|
-
function errorMessage(error: unknown): string {
|
|
57
|
+
export function errorMessage(error: unknown): string {
|
|
51
58
|
return error instanceof Error ? error.message : "An unexpected error occurred.";
|
|
52
59
|
}
|
|
53
60
|
|
|
@@ -69,7 +76,7 @@ function laneColor(lane: BeadLane, colors: PluginWorkspacePanelProps["theme"]["c
|
|
|
69
76
|
return colors.foregroundMuted;
|
|
70
77
|
}
|
|
71
78
|
|
|
72
|
-
function priorityColor(
|
|
79
|
+
export function priorityColor(
|
|
73
80
|
priority: number,
|
|
74
81
|
colors: PluginWorkspacePanelProps["theme"]["colors"],
|
|
75
82
|
): string {
|
|
@@ -214,13 +221,13 @@ function DetailPane({
|
|
|
214
221
|
styles,
|
|
215
222
|
theme,
|
|
216
223
|
}: {
|
|
217
|
-
backButtonRef
|
|
224
|
+
backButtonRef?: RefObject<View | null>;
|
|
218
225
|
detail: BeadDetail | null | undefined;
|
|
219
226
|
detailError: unknown;
|
|
220
227
|
isDetailFetching: boolean;
|
|
221
228
|
isDetailPending: boolean;
|
|
222
229
|
missing: boolean;
|
|
223
|
-
onBack(): void;
|
|
230
|
+
onBack?(): void;
|
|
224
231
|
onRetry(): void;
|
|
225
232
|
selectedId: string | null;
|
|
226
233
|
showBack: boolean;
|
|
@@ -434,6 +441,45 @@ export function PaseoBeads(props: PluginWorkspacePanelProps) {
|
|
|
434
441
|
return <WorkspaceBeads key={props.workspaceId} {...props} />;
|
|
435
442
|
}
|
|
436
443
|
|
|
444
|
+
export function BeadScreen({ theme, layout, host, params }: PluginScreenProps) {
|
|
445
|
+
const styles = useMemo(() => createStyles(theme, layout.compact), [layout.compact, theme]);
|
|
446
|
+
const target = parseBeadScreenParams(params);
|
|
447
|
+
const loadDetail = useRpc(getWorkspaceBead);
|
|
448
|
+
const { data, error, isFetching, isPending, refetch } = useQuery({
|
|
449
|
+
queryKey: ["paseo-beads", "detail", host.id, target?.workspaceId, target?.issueId],
|
|
450
|
+
queryFn: () => loadDetail(target as NonNullable<typeof target>),
|
|
451
|
+
enabled: target !== null,
|
|
452
|
+
refetchInterval: REFRESH_INTERVAL_MS,
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
return (
|
|
456
|
+
<View style={styles.screen}>
|
|
457
|
+
{target ? (
|
|
458
|
+
<DetailPane
|
|
459
|
+
detail={data?.detail}
|
|
460
|
+
detailError={error}
|
|
461
|
+
isDetailFetching={isFetching}
|
|
462
|
+
isDetailPending={isPending}
|
|
463
|
+
missing={data?.detail === null}
|
|
464
|
+
onRetry={() => void refetch()}
|
|
465
|
+
selectedId={target.issueId}
|
|
466
|
+
showBack={false}
|
|
467
|
+
styles={styles}
|
|
468
|
+
theme={theme}
|
|
469
|
+
/>
|
|
470
|
+
) : (
|
|
471
|
+
<StateCard
|
|
472
|
+
body="This link needs a workspace ID and a bead ID."
|
|
473
|
+
icon="AlertTriangle"
|
|
474
|
+
styles={styles}
|
|
475
|
+
theme={theme}
|
|
476
|
+
title="Invalid bead link"
|
|
477
|
+
/>
|
|
478
|
+
)}
|
|
479
|
+
</View>
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
437
483
|
function WorkspaceBeads({ theme, layout, host, workspaceId }: PluginWorkspacePanelProps) {
|
|
438
484
|
const styles = useMemo(() => createStyles(theme, layout.compact), [layout.compact, theme]);
|
|
439
485
|
const workspaceTitle = useWorkspace(workspaceId, ({ name, title }) => title?.trim() || name);
|
|
@@ -449,7 +495,7 @@ function WorkspaceBeads({ theme, layout, host, workspaceId }: PluginWorkspacePan
|
|
|
449
495
|
const listScrollOffsetRef = useRef(0);
|
|
450
496
|
|
|
451
497
|
const snapshotKey = useMemo(
|
|
452
|
-
() =>
|
|
498
|
+
() => beadsSnapshotQueryKey(host.id, workspaceId),
|
|
453
499
|
[host.id, workspaceId],
|
|
454
500
|
);
|
|
455
501
|
const {
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type PluginPopoverProps,
|
|
3
|
+
type PluginSidebarItemProps,
|
|
4
|
+
usePaseo,
|
|
5
|
+
useRpc,
|
|
6
|
+
} from "@getpaseo/plugin/client";
|
|
7
|
+
import { SidebarRow } from "@getpaseo/plugin/client/ui";
|
|
8
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
9
|
+
import { useMemo } from "react";
|
|
10
|
+
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native";
|
|
11
|
+
import { getWorkspaceBeads } from "../shared/beads";
|
|
12
|
+
import { BEAD_SCREEN_ID, describeReadyBeads } from "./beads-view";
|
|
13
|
+
import { errorMessage, ID_FONT_FAMILY, priorityColor } from "./paseo-beads";
|
|
14
|
+
import { READY_LIST_LIMIT, scanReadyBeads } from "./ready-scan";
|
|
15
|
+
|
|
16
|
+
/** The scan reads every project's Beads, so it runs rarely in the background and on popover open. */
|
|
17
|
+
const READY_REFRESH_INTERVAL_MS = 120_000;
|
|
18
|
+
|
|
19
|
+
function useReadyBeads(hostId: string, refetchOnMount: true | "always") {
|
|
20
|
+
const paseo = usePaseo();
|
|
21
|
+
const queryClient = useQueryClient();
|
|
22
|
+
const loadSnapshot = useRpc(getWorkspaceBeads);
|
|
23
|
+
return useQuery({
|
|
24
|
+
queryKey: ["paseo-beads", "ready", hostId],
|
|
25
|
+
queryFn: ({ signal }) =>
|
|
26
|
+
scanReadyBeads({
|
|
27
|
+
hostId,
|
|
28
|
+
signal,
|
|
29
|
+
queryClient,
|
|
30
|
+
loadSnapshot,
|
|
31
|
+
listWorkspaces: async () => (await paseo.workspaces.list()).entries,
|
|
32
|
+
}),
|
|
33
|
+
staleTime: READY_REFRESH_INTERVAL_MS / 2,
|
|
34
|
+
refetchInterval: READY_REFRESH_INTERVAL_MS,
|
|
35
|
+
refetchOnMount,
|
|
36
|
+
refetchOnWindowFocus: false,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function ReadyBeadsItem({
|
|
41
|
+
theme,
|
|
42
|
+
host,
|
|
43
|
+
currentScreen,
|
|
44
|
+
openPopover,
|
|
45
|
+
}: PluginSidebarItemProps) {
|
|
46
|
+
const { data } = useReadyBeads(host.id, true);
|
|
47
|
+
return (
|
|
48
|
+
<SidebarRow
|
|
49
|
+
icon="CircleDot"
|
|
50
|
+
active={currentScreen?.screenId === BEAD_SCREEN_ID}
|
|
51
|
+
onPress={() => openPopover(ReadyBeadsPopover)}
|
|
52
|
+
trailing={
|
|
53
|
+
data?.count ? (
|
|
54
|
+
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>{data.count}</Text>
|
|
55
|
+
) : null
|
|
56
|
+
}
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ReadyBeadsPopover({ theme, layout, host, openScreen }: PluginPopoverProps) {
|
|
62
|
+
const styles = useMemo(() => createStyles(theme, layout.compact), [layout.compact, theme]);
|
|
63
|
+
const { data, error } = useReadyBeads(host.id, "always");
|
|
64
|
+
|
|
65
|
+
if (!data) {
|
|
66
|
+
return (
|
|
67
|
+
<View style={styles.state}>
|
|
68
|
+
{error ? null : <ActivityIndicator color={theme.colors.accent} />}
|
|
69
|
+
<Text style={styles.note}>
|
|
70
|
+
{error ? `Could not load Beads. ${errorMessage(error)}` : "Loading ready beads…"}
|
|
71
|
+
</Text>
|
|
72
|
+
</View>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const { empty, notices } = describeReadyBeads(data, error ? errorMessage(error) : null);
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<View style={styles.list}>
|
|
80
|
+
{empty ? <Text style={styles.note}>{empty}</Text> : null}
|
|
81
|
+
{data.groups.map(({ workspace, beads }) => (
|
|
82
|
+
<View key={workspace.id} style={styles.group}>
|
|
83
|
+
<View style={styles.groupHeading}>
|
|
84
|
+
<Text accessibilityRole="header" style={styles.workspace} numberOfLines={1}>
|
|
85
|
+
{workspace.name}
|
|
86
|
+
</Text>
|
|
87
|
+
<Text style={styles.project} numberOfLines={1}>
|
|
88
|
+
{workspace.projectDisplayName}
|
|
89
|
+
</Text>
|
|
90
|
+
</View>
|
|
91
|
+
{beads.map((bead) => (
|
|
92
|
+
<Pressable
|
|
93
|
+
key={bead.id}
|
|
94
|
+
accessibilityRole="button"
|
|
95
|
+
accessibilityLabel={`Open ${bead.id}: ${bead.title}. Priority P${bead.priority}.`}
|
|
96
|
+
onPress={() =>
|
|
97
|
+
openScreen({
|
|
98
|
+
screenId: BEAD_SCREEN_ID,
|
|
99
|
+
params: { workspace: workspace.id, bead: bead.id },
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
style={({ pressed }) => [styles.row, pressed && styles.pressed]}
|
|
103
|
+
>
|
|
104
|
+
<Text
|
|
105
|
+
style={[styles.priority, { color: priorityColor(bead.priority, theme.colors) }]}
|
|
106
|
+
>
|
|
107
|
+
P{bead.priority}
|
|
108
|
+
</Text>
|
|
109
|
+
<Text style={styles.id}>{bead.id}</Text>
|
|
110
|
+
<Text style={styles.title} numberOfLines={1}>
|
|
111
|
+
{bead.title}
|
|
112
|
+
</Text>
|
|
113
|
+
</Pressable>
|
|
114
|
+
))}
|
|
115
|
+
</View>
|
|
116
|
+
))}
|
|
117
|
+
{data.count > READY_LIST_LIMIT ? (
|
|
118
|
+
<Text style={styles.note}>
|
|
119
|
+
Showing {READY_LIST_LIMIT} of {data.count} ready beads.
|
|
120
|
+
</Text>
|
|
121
|
+
) : null}
|
|
122
|
+
{notices.map((notice) => (
|
|
123
|
+
<Text key={notice} style={styles.note}>
|
|
124
|
+
{notice}
|
|
125
|
+
</Text>
|
|
126
|
+
))}
|
|
127
|
+
</View>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function createStyles(theme: PluginPopoverProps["theme"], compact: boolean) {
|
|
132
|
+
return StyleSheet.create({
|
|
133
|
+
list: { gap: 12 },
|
|
134
|
+
state: { alignItems: "center", gap: 8, paddingVertical: 12 },
|
|
135
|
+
group: { gap: 2 },
|
|
136
|
+
groupHeading: {
|
|
137
|
+
flexDirection: "row",
|
|
138
|
+
alignItems: "baseline",
|
|
139
|
+
gap: 6,
|
|
140
|
+
paddingHorizontal: 6,
|
|
141
|
+
paddingBottom: 2,
|
|
142
|
+
},
|
|
143
|
+
workspace: { flexShrink: 1, color: theme.colors.foreground, fontSize: 12, fontWeight: "700" },
|
|
144
|
+
project: { flexShrink: 1, color: theme.colors.foregroundMuted, fontSize: 11 },
|
|
145
|
+
row: {
|
|
146
|
+
minHeight: compact ? 44 : 32,
|
|
147
|
+
flexDirection: "row",
|
|
148
|
+
alignItems: "center",
|
|
149
|
+
gap: 8,
|
|
150
|
+
paddingHorizontal: 6,
|
|
151
|
+
borderRadius: 6,
|
|
152
|
+
},
|
|
153
|
+
pressed: { backgroundColor: theme.colors.surface2 },
|
|
154
|
+
priority: { fontSize: 11, fontWeight: "800" },
|
|
155
|
+
id: { color: theme.colors.foregroundMuted, fontSize: 11, fontFamily: ID_FONT_FAMILY },
|
|
156
|
+
title: { flex: 1, minWidth: 0, color: theme.colors.foreground, fontSize: 13 },
|
|
157
|
+
note: { color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 17 },
|
|
158
|
+
});
|
|
159
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
|
2
|
+
import type { BeadsSnapshot } from "../shared/beads";
|
|
3
|
+
import {
|
|
4
|
+
beadsSnapshotQueryKey,
|
|
5
|
+
type ReadyBeadsSummary,
|
|
6
|
+
type ReadyBeadsWorkspace,
|
|
7
|
+
summarizeReadyBeads,
|
|
8
|
+
} from "./beads-view";
|
|
9
|
+
|
|
10
|
+
/** How many ready beads the popover lists; the sidebar count covers all of them. */
|
|
11
|
+
export const READY_LIST_LIMIT = 10;
|
|
12
|
+
/** The panel's polling interval: a snapshot an open panel just read is reused, not refetched. */
|
|
13
|
+
const SNAPSHOT_FRESH_MS = 10_000;
|
|
14
|
+
/** A workspace without Beads is rechecked this rarely: it seldom changes and each check runs bd. */
|
|
15
|
+
const NO_BEADS_FRESH_MS = 5 * 60_000;
|
|
16
|
+
/** How long a successful read vouches for the database a workspace reads. */
|
|
17
|
+
const DATABASE_MEMORY_MS = 10 * 60_000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The database a workspace was last confirmed to read, or null when nothing vouches for it. The
|
|
21
|
+
* cache cannot decide this on its own: `gcTime` only drops queries nobody observes, and an open
|
|
22
|
+
* panel keeps a failing query's old data indefinitely. So the data must not be invalidated (a
|
|
23
|
+
* failed refresh keeps the old data but flags it) and must be recent.
|
|
24
|
+
*/
|
|
25
|
+
function confirmedDatabase(queryClient: QueryClient, queryKey: QueryKey): string | null {
|
|
26
|
+
const state = queryClient.getQueryState<BeadsSnapshot>(queryKey);
|
|
27
|
+
if (!state || state.isInvalidated) return null;
|
|
28
|
+
if (Date.now() - state.dataUpdatedAt > DATABASE_MEMORY_MS) return null;
|
|
29
|
+
return state.data?.databaseId ?? null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ReadyBeadsScan {
|
|
33
|
+
hostId: string;
|
|
34
|
+
/** Stops the scan from starting more workspace reads once its query is cancelled. */
|
|
35
|
+
signal: AbortSignal;
|
|
36
|
+
queryClient: QueryClient;
|
|
37
|
+
listWorkspaces(): Promise<readonly ReadyBeadsWorkspace[]>;
|
|
38
|
+
loadSnapshot(input: { workspaceId: string }): Promise<BeadsSnapshot>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Reads each workspace's Beads snapshot through the cache the panel polls, and summarizes the
|
|
43
|
+
* ready beads. A workspace is skipped only when it is confirmed to read a database that this scan
|
|
44
|
+
* has read, so the first scan reads every workspace and learns which ones share a database (git
|
|
45
|
+
* worktrees do), and later scans read each database once. A workspace that cannot be read counts
|
|
46
|
+
* as failed.
|
|
47
|
+
*/
|
|
48
|
+
export async function scanReadyBeads({
|
|
49
|
+
hostId,
|
|
50
|
+
signal,
|
|
51
|
+
queryClient,
|
|
52
|
+
listWorkspaces,
|
|
53
|
+
loadSnapshot,
|
|
54
|
+
}: ReadyBeadsScan): Promise<ReadyBeadsSummary> {
|
|
55
|
+
const readDatabases = new Set<string>();
|
|
56
|
+
const loaded: { workspace: ReadyBeadsWorkspace; snapshot: BeadsSnapshot | null }[] = [];
|
|
57
|
+
// ponytail: first page of the workspace list (up to 200), read one at a time so bd load stays
|
|
58
|
+
// flat. Add a small pool if the count lags on hosts with many Beads projects.
|
|
59
|
+
for (const workspace of await listWorkspaces()) {
|
|
60
|
+
if (signal.aborted) throw new Error("The ready beads scan was cancelled.");
|
|
61
|
+
const queryKey = beadsSnapshotQueryKey(hostId, workspace.id);
|
|
62
|
+
const knownDatabase = confirmedDatabase(queryClient, queryKey);
|
|
63
|
+
if (knownDatabase && readDatabases.has(knownDatabase)) continue;
|
|
64
|
+
// fetchQuery, not query(): the host app's query-core (5.90) has no query().
|
|
65
|
+
const snapshot = await queryClient
|
|
66
|
+
.fetchQuery({
|
|
67
|
+
queryKey,
|
|
68
|
+
queryFn: () => loadSnapshot({ workspaceId: workspace.id }),
|
|
69
|
+
staleTime: (query) =>
|
|
70
|
+
query.state.data?.state === "not_initialized" ? NO_BEADS_FRESH_MS : SNAPSHOT_FRESH_MS,
|
|
71
|
+
gcTime: DATABASE_MEMORY_MS, // storage cleanup only; confirmedDatabase enforces the age
|
|
72
|
+
})
|
|
73
|
+
.catch(() => null);
|
|
74
|
+
if (snapshot?.databaseId) readDatabases.add(snapshot.databaseId);
|
|
75
|
+
loaded.push({ workspace, snapshot });
|
|
76
|
+
}
|
|
77
|
+
return summarizeReadyBeads(loaded, READY_LIST_LIMIT);
|
|
78
|
+
}
|
package/index.client.tsx
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
2
|
-
import {
|
|
2
|
+
import { BEAD_SCREEN_ID, parseBeadScreenParams } from "./client/beads-view";
|
|
3
|
+
import { BeadScreen, PaseoBeads } from "./client/paseo-beads";
|
|
4
|
+
import { ReadyBeadsItem } from "./client/ready-beads";
|
|
3
5
|
|
|
4
6
|
export default function contribute(client: PluginClientContext) {
|
|
5
7
|
const removePanel = client.addWorkspacePanel({
|
|
@@ -30,8 +32,25 @@ export default function contribute(client: PluginClientContext) {
|
|
|
30
32
|
openPanel("beads");
|
|
31
33
|
},
|
|
32
34
|
});
|
|
35
|
+
// Paseo 0.11 added screens and sidebar header items. 0.9 and 0.10 keep the panel and commands.
|
|
36
|
+
const removeReadyBeads =
|
|
37
|
+
typeof client.addScreen === "function" && typeof client.addSidebarHeaderItem === "function"
|
|
38
|
+
? [
|
|
39
|
+
client.addScreen({
|
|
40
|
+
id: BEAD_SCREEN_ID,
|
|
41
|
+
title: (params) => parseBeadScreenParams(params)?.issueId ?? "Bead",
|
|
42
|
+
Component: BeadScreen,
|
|
43
|
+
}),
|
|
44
|
+
client.addSidebarHeaderItem({
|
|
45
|
+
id: "ready-beads",
|
|
46
|
+
title: "Ready beads",
|
|
47
|
+
Component: ReadyBeadsItem,
|
|
48
|
+
}),
|
|
49
|
+
]
|
|
50
|
+
: [];
|
|
33
51
|
|
|
34
52
|
return () => {
|
|
53
|
+
for (const remove of removeReadyBeads) remove();
|
|
35
54
|
removeAgentCommand();
|
|
36
55
|
removeWorkspaceCommand();
|
|
37
56
|
removePanel();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omercnet/paseo-beads",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0-next.214.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A dependency-aware Beads work queue for each Paseo workspace.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -49,10 +49,10 @@
|
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@biomejs/biome": "^2.5.10",
|
|
52
|
-
"@getpaseo/cli": "0.
|
|
53
|
-
"@getpaseo/client": "0.
|
|
54
|
-
"@getpaseo/plugin": "0.
|
|
55
|
-
"@getpaseo/protocol": "0.
|
|
52
|
+
"@getpaseo/cli": "0.11.0-beta.3",
|
|
53
|
+
"@getpaseo/client": "0.11.0-beta.3",
|
|
54
|
+
"@getpaseo/plugin": "0.11.0-beta.3",
|
|
55
|
+
"@getpaseo/protocol": "0.11.0-beta.3",
|
|
56
56
|
"@tanstack/react-query": "^5.102.3",
|
|
57
57
|
"@types/node": "^24.10.1",
|
|
58
58
|
"@types/react": "^19.2.18",
|
package/paseo-plugin.json
CHANGED
package/server/beads.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { promisify } from "node:util";
|
|
3
4
|
import type { RpcInput, RpcOutput } from "@getpaseo/plugin";
|
|
4
5
|
import type { PluginHandlerContext } from "@getpaseo/plugin/server";
|
|
@@ -18,11 +19,16 @@ const execFileAsync = promisify(execFile);
|
|
|
18
19
|
const COMMAND_TIMEOUT_MS = 10_000;
|
|
19
20
|
const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
|
20
21
|
const MAX_DIAGNOSTIC_CHARS = 512;
|
|
22
|
+
const MAX_PATH_CHARS = 4_096;
|
|
21
23
|
|
|
22
24
|
const rawIdentifierSchema = z.string().max(BEADS_LIMITS.identifier);
|
|
23
25
|
const rawTitleSchema = z.string().max(BEADS_LIMITS.title);
|
|
24
26
|
const rawStatusOrTypeSchema = z.string().max(BEADS_LIMITS.statusOrType);
|
|
25
27
|
const rawTimestampSchema = z.string().max(BEADS_LIMITS.timestamp).datetime();
|
|
28
|
+
const RawDatabaseLocationSchema = z.object({
|
|
29
|
+
database_path: z.string().min(1).max(MAX_PATH_CHARS).optional(),
|
|
30
|
+
path: z.string().min(1).max(MAX_PATH_CHARS).optional(),
|
|
31
|
+
});
|
|
26
32
|
|
|
27
33
|
const RawBeadSummarySchema = z.object({
|
|
28
34
|
id: rawIdentifierSchema,
|
|
@@ -141,6 +147,24 @@ async function loadReadyIds(
|
|
|
141
147
|
return new Set(RawReadyIssueListSchema.parse(result).map(({ id }) => id));
|
|
142
148
|
}
|
|
143
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Names the database `bd` resolves for a directory, so workspaces that share one (git worktrees,
|
|
152
|
+
* redirects) can be counted once. The ID is a digest, so no path leaves the daemon. Best effort:
|
|
153
|
+
* an older `bd` or a failing `bd where` leaves it null, and the list and ready queries still decide
|
|
154
|
+
* the snapshot's state.
|
|
155
|
+
*/
|
|
156
|
+
async function loadDatabaseId(directory: string, runner: BdCommandRunner): Promise<string | null> {
|
|
157
|
+
try {
|
|
158
|
+
const location = RawDatabaseLocationSchema.parse(
|
|
159
|
+
await runBd(directory, ["where", "--json"], runner),
|
|
160
|
+
);
|
|
161
|
+
const database = location.database_path ?? location.path;
|
|
162
|
+
return database ? createHash("sha256").update(database).digest("hex").slice(0, 16) : null;
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
144
168
|
function errorText(error: unknown): string {
|
|
145
169
|
if (!(error instanceof Error)) {
|
|
146
170
|
return typeof error === "string"
|
|
@@ -249,6 +273,7 @@ function unavailableSnapshot(state: "not_initialized" | "bd_unavailable") {
|
|
|
249
273
|
issues: [],
|
|
250
274
|
truncated: false,
|
|
251
275
|
refreshedAt: new Date().toISOString(),
|
|
276
|
+
databaseId: null,
|
|
252
277
|
message:
|
|
253
278
|
state === "bd_unavailable"
|
|
254
279
|
? "The bd CLI is not available on this Paseo host."
|
|
@@ -264,13 +289,14 @@ export async function handleGetWorkspaceBeads(
|
|
|
264
289
|
const directory = await resolveWorkspaceDirectory(workspaceId, context);
|
|
265
290
|
|
|
266
291
|
try {
|
|
267
|
-
const [listJson, readyIds] = await Promise.all([
|
|
292
|
+
const [listJson, readyIds, databaseId] = await Promise.all([
|
|
268
293
|
runBd(
|
|
269
294
|
directory,
|
|
270
295
|
["list", "--json", "--sort", "priority", "--limit", String(BEADS_LIMITS.rawIssueList)],
|
|
271
296
|
runner,
|
|
272
297
|
),
|
|
273
298
|
loadReadyIds(directory, runner),
|
|
299
|
+
loadDatabaseId(directory, runner),
|
|
274
300
|
]);
|
|
275
301
|
const listed = RawIssueListSchema.parse(listJson);
|
|
276
302
|
const truncated = listed.length > BEADS_LIMITS.issueList;
|
|
@@ -282,6 +308,7 @@ export async function handleGetWorkspaceBeads(
|
|
|
282
308
|
truncated,
|
|
283
309
|
refreshedAt: new Date().toISOString(),
|
|
284
310
|
message: null,
|
|
311
|
+
databaseId,
|
|
285
312
|
});
|
|
286
313
|
} catch (error) {
|
|
287
314
|
if (isBdUnavailable(error)) return unavailableSnapshot("bd_unavailable");
|
package/shared/beads.ts
CHANGED
|
@@ -62,6 +62,11 @@ export const BeadsSnapshotSchema = z.object({
|
|
|
62
62
|
truncated: z.boolean(),
|
|
63
63
|
refreshedAt: timestampSchema,
|
|
64
64
|
message: z.string().max(BEADS_LIMITS.publicMessage).nullable(),
|
|
65
|
+
/**
|
|
66
|
+
* Opaque ID of the Beads database behind the snapshot. Workspaces that share one database, such
|
|
67
|
+
* as git worktrees, report the same ID. Null or absent when `bd` could not say.
|
|
68
|
+
*/
|
|
69
|
+
databaseId: identifierSchema.nullish(),
|
|
65
70
|
});
|
|
66
71
|
|
|
67
72
|
export type BeadSummary = z.infer<typeof BeadSummarySchema>;
|