@gperezmz/bb-plugin-thread-glance 0.3.0 → 0.4.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/dist/app.css +1 -1
- package/dist/app.js +29 -29
- package/dist/app.meta.json +1 -1
- package/dist/server.js.map +1 -1
- package/dist/server.meta.json +1 -1
- package/features/thread-list/components/ThreadList.tsx +2 -11
- package/features/thread-list/components/ThreadRowView.tsx +160 -129
- package/features/thread-list/components/glyphs.tsx +10 -29
- package/features/thread-list/model/labels.ts +20 -6
- package/features/thread-list/model/settled.ts +4 -66
- package/features/thread-list/model/state.ts +9 -21
- package/features/thread-list/model/time.ts +7 -0
- package/features/thread-list/model/trees.ts +11 -11
- package/features/thread-list/model/view.ts +14 -12
- package/features/thread-list/testing/browser.css +13 -0
- package/features/thread-list/testing/fixtures.ts +1 -7
- package/package.json +5 -1
- package/skills/thread-glance/SKILL.md +1 -1
- package/features/thread-list/components/PullRequestProbes.tsx +0 -27
- package/features/thread-list/data/usePullRequestAnswers.ts +0 -36
|
@@ -105,3 +105,10 @@ export function stateSince(
|
|
|
105
105
|
if (at === undefined) return null;
|
|
106
106
|
return formatDuration(now - at);
|
|
107
107
|
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The widest texts `trailingTime` gives up to 99 weeks, one per unit. The
|
|
111
|
+
* trailing slot is as wide as the widest of them, so times line up down the
|
|
112
|
+
* list; an older row widens its own.
|
|
113
|
+
*/
|
|
114
|
+
export const TRAILING_SLOT_SIZERS = ["now", "<1m", "59m", "23h", "6d", "99w"] as const;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
|
|
4
4
|
import {
|
|
5
5
|
computeState,
|
|
6
|
-
|
|
6
|
+
chipFlagsOf,
|
|
7
7
|
hiddenThreadFlags,
|
|
8
8
|
isQuietThread,
|
|
9
9
|
isUnread,
|
|
@@ -29,8 +29,8 @@ export interface ThreadInfo {
|
|
|
29
29
|
unread: boolean;
|
|
30
30
|
/** Own flags; for hidden threads only waits-on-you and unread-failed. */
|
|
31
31
|
flags: ReadonlySet<Flag>;
|
|
32
|
-
/** What it adds to the
|
|
33
|
-
|
|
32
|
+
/** What it adds to the children chip of the threads above it (see `chipFlagsOf`). */
|
|
33
|
+
chipFlags: ReadonlySet<Flag>;
|
|
34
34
|
/**
|
|
35
35
|
* The flags of this thread that make it need attention, for collapsed
|
|
36
36
|
* groups, the counters, the need-you filter and auto-reveal: a child counts
|
|
@@ -55,14 +55,14 @@ export interface ThreadInfo {
|
|
|
55
55
|
note: RowNote | null;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/** What a thread's descendants add up to, for its chip
|
|
58
|
+
/** What a thread's descendants add up to, for its chip and folding. */
|
|
59
59
|
export interface Subtree {
|
|
60
60
|
/** Descendants, depth-first in creation order, hidden ones included. */
|
|
61
61
|
descendants: ThreadInfo[];
|
|
62
62
|
/** Union of the descendants' Needs attention flags and working, archived ones left out. */
|
|
63
63
|
flags: ReadonlySet<Flag>;
|
|
64
|
-
/** Union of the descendants' `
|
|
65
|
-
|
|
64
|
+
/** Union of the descendants' `chipFlags`, archived ones left out: the chip's state. */
|
|
65
|
+
chipFlags: ReadonlySet<Flag>;
|
|
66
66
|
/** Visible descendants at any depth. */
|
|
67
67
|
visibleCount: number;
|
|
68
68
|
/** Visible direct children: the chip's number. */
|
|
@@ -167,7 +167,7 @@ export function buildForest(inputs: ForestInputs): Forest {
|
|
|
167
167
|
state,
|
|
168
168
|
unread,
|
|
169
169
|
flags: thread.isHidden ? hiddenThreadFlags(flags) : flags,
|
|
170
|
-
|
|
170
|
+
chipFlags: chipFlagsOf(flags, thread.isHidden),
|
|
171
171
|
attentionFlags: new Set<Flag>(),
|
|
172
172
|
quiet: isQuietThread(state, unread, isActive),
|
|
173
173
|
quietIgnoringOpen: false,
|
|
@@ -216,7 +216,7 @@ export function buildForest(inputs: ForestInputs): Forest {
|
|
|
216
216
|
if (cached !== undefined) return cached;
|
|
217
217
|
const descendants: ThreadInfo[] = [];
|
|
218
218
|
const flags = new Set<Flag>();
|
|
219
|
-
const
|
|
219
|
+
const chipFlags = new Set<Flag>();
|
|
220
220
|
let visibleCount = 0;
|
|
221
221
|
let childCount = 0;
|
|
222
222
|
let quietIgnoringOpen = true;
|
|
@@ -229,10 +229,10 @@ export function buildForest(inputs: ForestInputs): Forest {
|
|
|
229
229
|
if (!child.thread.isArchived) {
|
|
230
230
|
for (const flag of child.attentionFlags) flags.add(flag);
|
|
231
231
|
if (child.flags.has("working")) flags.add("working");
|
|
232
|
-
for (const flag of child.
|
|
232
|
+
for (const flag of child.chipFlags) chipFlags.add(flag);
|
|
233
233
|
}
|
|
234
234
|
for (const flag of below.flags) flags.add(flag);
|
|
235
|
-
for (const flag of below.
|
|
235
|
+
for (const flag of below.chipFlags) chipFlags.add(flag);
|
|
236
236
|
if (child.thread.isHidden ? child.attentionFlags.size > 0 : !child.quietIgnoringOpen) quietIgnoringOpen = false;
|
|
237
237
|
if (!child.thread.isHidden) {
|
|
238
238
|
visibleCount += 1;
|
|
@@ -242,7 +242,7 @@ export function buildForest(inputs: ForestInputs): Forest {
|
|
|
242
242
|
if (!below.quietIgnoringOpen) quietIgnoringOpen = false;
|
|
243
243
|
}
|
|
244
244
|
path.delete(id);
|
|
245
|
-
const subtree: Subtree = { descendants, flags,
|
|
245
|
+
const subtree: Subtree = { descendants, flags, chipFlags, visibleCount, childCount, quietIgnoringOpen };
|
|
246
246
|
subtrees.set(id, subtree);
|
|
247
247
|
return subtree;
|
|
248
248
|
};
|
|
@@ -29,12 +29,18 @@ import type { RowNote } from "./notes";
|
|
|
29
29
|
/** How many quiet children stay in an expanded tree. */
|
|
30
30
|
export const KEEP_QUIET_CHILDREN = 3;
|
|
31
31
|
|
|
32
|
-
/** The children chip: the
|
|
32
|
+
/** The children chip: the count of a parent's direct children and its chevron, after the state of its descendants. */
|
|
33
33
|
export interface Chip {
|
|
34
|
-
/** Direct children opening it shows, hidden ones left out. */
|
|
34
|
+
/** Direct children opening it shows, hidden ones left out; 0 draws no number. */
|
|
35
35
|
count: number;
|
|
36
36
|
/** The user opened the chip, so every child shows. */
|
|
37
37
|
expanded: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* The most urgent state among the descendants at any depth, open or
|
|
40
|
+
* collapsed, or null for none: its glyph leads the chip and colours it, and
|
|
41
|
+
* keeps that colour through dimming and hover.
|
|
42
|
+
*/
|
|
43
|
+
flag: Flag | null;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
export interface ThreadRow {
|
|
@@ -53,11 +59,6 @@ export interface ThreadRow {
|
|
|
53
59
|
* default, a child whose harness is not its parent thread's.
|
|
54
60
|
*/
|
|
55
61
|
harness: boolean;
|
|
56
|
-
/**
|
|
57
|
-
* The child dot on the status glyph: the most urgent state among the
|
|
58
|
-
* thread's descendants at any depth, or null for none.
|
|
59
|
-
*/
|
|
60
|
-
childDot: Flag | null;
|
|
61
62
|
/** The title is bold: the thread is unread. */
|
|
62
63
|
bold: boolean;
|
|
63
64
|
/** A root whose tree holds an unread thread: it offers Mark read for the whole tree. */
|
|
@@ -267,7 +268,6 @@ function threadRow(
|
|
|
267
268
|
parentTitle: titleOf(context, info.parentId),
|
|
268
269
|
chip: options.chip,
|
|
269
270
|
harness: drawsHarness(context, info),
|
|
270
|
-
childDot: mostUrgent(subtreeOf(context, info.thread.id).dotFlags),
|
|
271
271
|
bold: info.unread,
|
|
272
272
|
treeUnread: info === root && [root, ...(context.forest.treeOf.get(root.thread.id)?.descendants ?? [])].some((info) => info.unread),
|
|
273
273
|
note: info.note,
|
|
@@ -405,11 +405,13 @@ function clusterEnvironments(context: Context, units: Unit[], depth: number): Tr
|
|
|
405
405
|
return rows;
|
|
406
406
|
}
|
|
407
407
|
|
|
408
|
-
/** The children chip of a parent, or null when
|
|
408
|
+
/** The children chip of a parent, or null when it would show nothing. */
|
|
409
409
|
function chipOf(context: Context, info: ThreadInfo, expanded: boolean): Chip | null {
|
|
410
|
-
const count = subtreeOf(context, info.thread.id)
|
|
411
|
-
|
|
412
|
-
|
|
410
|
+
const { childCount: count, chipFlags } = subtreeOf(context, info.thread.id);
|
|
411
|
+
const flag = mostUrgent(chipFlags);
|
|
412
|
+
// A hidden child's failure that needs no attention has no row, yet the chip still shows it.
|
|
413
|
+
if (count === 0 && flag === null && eligibleChildren(context, info.thread.id).length === 0) return null;
|
|
414
|
+
return { count, expanded, flag };
|
|
413
415
|
}
|
|
414
416
|
|
|
415
417
|
/** What a thread and everything under it carry, for an environment folder's glyph. */
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
/* The tests measure where the row's boxes end up; fades would only delay it. */
|
|
4
|
+
*,
|
|
5
|
+
*::before,
|
|
6
|
+
*::after {
|
|
7
|
+
transition: none !important;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/* bb draws icons as sized SVGs; the test SDK's stand-in is a bare span. */
|
|
11
|
+
[data-icon] {
|
|
12
|
+
display: inline-block;
|
|
13
|
+
}
|
|
@@ -8,7 +8,7 @@ import { defaultPreferences, type Preferences } from "@/shared/preferences";
|
|
|
8
8
|
import { buildForest, type Forest } from "../model/trees";
|
|
9
9
|
import { buildListView, type ListView, type Row } from "../model/view";
|
|
10
10
|
import type { Targets } from "../model/expansion";
|
|
11
|
-
import
|
|
11
|
+
import type { SettleInputs } from "../model/settled";
|
|
12
12
|
|
|
13
13
|
export const T0 = 1_780_000_000_000;
|
|
14
14
|
|
|
@@ -125,8 +125,6 @@ export interface Scenario {
|
|
|
125
125
|
needYouOnly?: boolean;
|
|
126
126
|
/** bb's default harness; "claude-code", the fixtures' own, when absent. */
|
|
127
127
|
defaultProviderId?: string | null;
|
|
128
|
-
/** Pull request lookups that answered, by thread id. */
|
|
129
|
-
pullRequests?: Record<string, PullRequestState | null>;
|
|
130
128
|
}
|
|
131
129
|
|
|
132
130
|
export function forestOf(scenario: Scenario): Forest {
|
|
@@ -165,15 +163,11 @@ export function viewOf(scenario: Scenario): ListView {
|
|
|
165
163
|
|
|
166
164
|
/** The settle inputs a scenario stands for. */
|
|
167
165
|
export function settleOf(scenario: Scenario): SettleInputs {
|
|
168
|
-
const answers = new Map(Object.entries(scenario.pullRequests ?? {}));
|
|
169
|
-
const defaultBranchOf = (projectId: string) =>
|
|
170
|
-
scenario.defaultBranches === undefined ? "main" : scenario.defaultBranches[projectId];
|
|
171
166
|
return {
|
|
172
167
|
now: scenario.now ?? T0 + 60_000,
|
|
173
168
|
settleAfter: scenario.prefs?.settleAfter ?? defaultPreferences().settleAfter,
|
|
174
169
|
startedAt: scenario.startedAt ?? {},
|
|
175
170
|
finishedAt: scenario.finishedAt ?? {},
|
|
176
|
-
pullRequestOf: (thread) => pullRequestFact(thread, defaultBranchOf(thread.projectId), answers),
|
|
177
171
|
};
|
|
178
172
|
}
|
|
179
173
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gperezmz/bb-plugin-thread-glance",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"bb": ">=0.43",
|
|
@@ -34,12 +34,14 @@
|
|
|
34
34
|
"@radix-ui/react-popover": "^1.1.19",
|
|
35
35
|
"@radix-ui/react-select": "^2.3.3",
|
|
36
36
|
"@radix-ui/react-tooltip": "^1.2.12",
|
|
37
|
+
"@tailwindcss/vite": "^4.3.3",
|
|
37
38
|
"@testing-library/dom": "^10.4.2",
|
|
38
39
|
"@testing-library/react": "^16.3.3",
|
|
39
40
|
"@types/better-sqlite3": "^7.6.12",
|
|
40
41
|
"@types/node": "^22.0.0",
|
|
41
42
|
"@types/react": "^19.0.0",
|
|
42
43
|
"@types/react-dom": "^19.0.0",
|
|
44
|
+
"@vitest/browser-playwright": "5.0.1",
|
|
43
45
|
"better-sqlite3": "^12.0.0",
|
|
44
46
|
"class-variance-authority": "^0.7.1",
|
|
45
47
|
"clsx": "^2.1.1",
|
|
@@ -47,10 +49,12 @@
|
|
|
47
49
|
"esbuild": "^0.28.2",
|
|
48
50
|
"hono": "^4.11.9",
|
|
49
51
|
"jsdom": "^30.1.1",
|
|
52
|
+
"playwright": "^1.63.0",
|
|
50
53
|
"react": "^19.3.0",
|
|
51
54
|
"react-dom": "^19.3.0",
|
|
52
55
|
"sonner": "^1.7.4",
|
|
53
56
|
"tailwind-merge": "^3.4.0",
|
|
57
|
+
"tailwindcss": "^4.3.3",
|
|
54
58
|
"typescript": "^7.0.2",
|
|
55
59
|
"vaul": "^1.1.2",
|
|
56
60
|
"vitest": "^5.0.1"
|
|
@@ -52,7 +52,7 @@ and `bb environment list`; a thread id is a `thr_…` id.
|
|
|
52
52
|
| `collapsedThreadSections` | Custom section keys, `section:<id>` | `[]` |
|
|
53
53
|
| `collapsedMachines` | Machine ids | `[]` |
|
|
54
54
|
| `collapsedEnvironments` | Environment ids of collapsed folder rows | `[]` |
|
|
55
|
-
| `settleAfter` | Settle after: how long a quiet thread goes without activity before its tree can settle into the group's "Settled (N)" fold: `12h`, `1d`, `3d`, `1w` or `never` (then
|
|
55
|
+
| `settleAfter` | Settle after: how long a quiet thread goes without activity before its tree can settle into the group's "Settled (N)" fold: `12h`, `1d`, `3d`, `1w` or `never` (then nothing settles) | `1d` |
|
|
56
56
|
| `openSettledFolds` | Group ids whose settled fold is open | `[]` |
|
|
57
57
|
| `expandedOlder` | Parent thread ids whose "N more child threads" row is open, once that tree's children chip is open | `[]` |
|
|
58
58
|
| `expandedChildren` | Parent thread ids whose chip is open | `[]` |
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
// Looks up the pull request of every thread settling has to know about and
|
|
2
|
-
// reports each answer. Draws nothing: the host shares one lookup per
|
|
3
|
-
// environment with the badges that ask for the same threads.
|
|
4
|
-
import { memo, useEffect } from "react";
|
|
5
|
-
import { experimental_useSidebarThreadPullRequest as usePullRequest } from "@get-bb/plugin-sdk/app";
|
|
6
|
-
import type { PullRequestState } from "../model/settled";
|
|
7
|
-
|
|
8
|
-
type Answer = (threadId: string, state: PullRequestState | null) => void;
|
|
9
|
-
|
|
10
|
-
const Probe = memo(function Probe({ threadId, onAnswer }: { threadId: string; onAnswer: Answer }) {
|
|
11
|
-
const { isLoading, pullRequest } = usePullRequest(threadId);
|
|
12
|
-
const state = isLoading ? undefined : (pullRequest?.state ?? null);
|
|
13
|
-
useEffect(() => {
|
|
14
|
-
if (state !== undefined) onAnswer(threadId, state);
|
|
15
|
-
}, [threadId, state, onAnswer]);
|
|
16
|
-
return null;
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
export function PullRequestProbes({ threadIds, onAnswer }: { threadIds: readonly string[]; onAnswer: Answer }) {
|
|
20
|
-
return (
|
|
21
|
-
<>
|
|
22
|
-
{threadIds.map((threadId) => (
|
|
23
|
-
<Probe key={threadId} threadId={threadId} onAnswer={onAnswer} />
|
|
24
|
-
))}
|
|
25
|
-
</>
|
|
26
|
-
);
|
|
27
|
-
}
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
// The pull request lookups that answered, by thread id. Answers arriving in
|
|
2
|
-
// one burst (a list's worth of lookups on load) land in one state update.
|
|
3
|
-
//
|
|
4
|
-
// Debt: this copies server state the host's per-row pull request hook owns,
|
|
5
|
-
// because settling reads every thread's pull request at once and the SDK
|
|
6
|
-
// offers no query over many threads. It clears when the SDK does.
|
|
7
|
-
import { useCallback, useRef, useState } from "react";
|
|
8
|
-
import type { PullRequestState } from "../model/settled";
|
|
9
|
-
|
|
10
|
-
export type PullRequestAnswers = ReadonlyMap<string, PullRequestState | null>;
|
|
11
|
-
|
|
12
|
-
export function usePullRequestAnswers(): [PullRequestAnswers, (threadId: string, state: PullRequestState | null) => void] {
|
|
13
|
-
const [answers, setAnswers] = useState<PullRequestAnswers>(() => new Map());
|
|
14
|
-
const pending = useRef(new Map<string, PullRequestState | null>());
|
|
15
|
-
const scheduled = useRef(false);
|
|
16
|
-
const onAnswer = useCallback((threadId: string, state: PullRequestState | null) => {
|
|
17
|
-
pending.current.set(threadId, state);
|
|
18
|
-
if (scheduled.current) return;
|
|
19
|
-
scheduled.current = true;
|
|
20
|
-
setTimeout(() => {
|
|
21
|
-
scheduled.current = false;
|
|
22
|
-
const batch = pending.current;
|
|
23
|
-
pending.current = new Map();
|
|
24
|
-
setAnswers((current) => {
|
|
25
|
-
let next: Map<string, PullRequestState | null> | null = null;
|
|
26
|
-
for (const [id, value] of batch) {
|
|
27
|
-
if (current.has(id) && current.get(id) === value) continue;
|
|
28
|
-
next ??= new Map(current);
|
|
29
|
-
next.set(id, value);
|
|
30
|
-
}
|
|
31
|
-
return next ?? current;
|
|
32
|
-
});
|
|
33
|
-
}, 0);
|
|
34
|
-
}, []);
|
|
35
|
-
return [answers, onAnswer];
|
|
36
|
-
}
|