@gperezmz/bb-plugin-thread-glance 0.4.1 → 0.5.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/dist/app.css +1 -1
- package/dist/app.js +30 -30
- package/dist/app.meta.json +1 -1
- package/dist/server.js +10 -10
- package/dist/server.js.map +3 -3
- package/dist/server.meta.json +1 -1
- package/features/thread-list/components/FoldRows.tsx +3 -3
- package/features/thread-list/components/GroupSection.tsx +10 -7
- package/features/thread-list/components/ListHeader.tsx +9 -4
- package/features/thread-list/components/ProviderBadge.tsx +1 -1
- package/features/thread-list/components/PullRequestBadge.tsx +1 -1
- package/features/thread-list/components/ThreadList.tsx +34 -13
- package/features/thread-list/components/ThreadRowView.tsx +9 -10
- package/features/thread-list/components/controller.ts +1 -1
- package/features/thread-list/components/glyphs.tsx +7 -4
- package/features/thread-list/components/input-modality.ts +51 -0
- package/features/thread-list/components/row-card.ts +1 -1
- package/features/thread-list/data/useIdleSince.ts +27 -0
- package/features/thread-list/data/useNow.ts +4 -3
- package/features/thread-list/data/useStamps.ts +19 -4
- package/features/thread-list/model/attention.ts +71 -7
- package/features/thread-list/model/expansion.ts +1 -1
- package/features/thread-list/model/settled.ts +1 -1
- package/features/thread-list/model/state.ts +18 -5
- package/features/thread-list/model/toggles.ts +3 -3
- package/features/thread-list/model/trees.ts +46 -9
- package/features/thread-list/model/view.ts +11 -5
- package/features/thread-list/testing/browser.css +7 -0
- package/features/thread-list/testing/fixtures.ts +10 -0
- package/package.json +1 -1
- package/server/stamps.ts +21 -3
- package/server.ts +5 -0
- package/shared/contract.ts +8 -0
- package/shared/signals.ts +2 -0
- package/skills/thread-glance/SKILL.md +1 -1
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// builds. Pure.
|
|
4
4
|
import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
|
|
5
5
|
import type { ChildAttention } from "@/shared/preferences";
|
|
6
|
-
import { type Flag, type StateKind, type ThreadState } from "./state";
|
|
6
|
+
import { computeState, type Flag, type StateKind, type ThreadState } from "./state";
|
|
7
7
|
|
|
8
8
|
/** The flags that count for a root thread. Working is not one. */
|
|
9
9
|
export const ROOT_ATTENTION: ReadonlySet<Flag> = new Set<Flag>([
|
|
@@ -17,11 +17,24 @@ export const ROOT_ATTENTION: ReadonlySet<Flag> = new Set<Flag>([
|
|
|
17
17
|
/** States in which a parent thread is busy and may still deal with a failed child. */
|
|
18
18
|
const BUSY: ReadonlySet<StateKind> = new Set<StateKind>(["working", "background", "queued", "scheduled"]);
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* How long a parent thread stays idle before a child's failure counts as
|
|
22
|
+
* orphaned. bb reports a child's turn to its parent after a 2 s batch delay,
|
|
23
|
+
* and the parent then has to start: a failure it is about to pick up never
|
|
24
|
+
* reaches the need-you filter.
|
|
25
|
+
*/
|
|
26
|
+
export const ORPHAN_WAIT_MS = 5_000;
|
|
27
|
+
|
|
20
28
|
export interface ParentThread {
|
|
21
29
|
thread: Pick<PluginSidebarThread, "latestAttentionAt">;
|
|
22
30
|
state: Pick<ThreadState, "kind">;
|
|
23
31
|
/** When the plugin server last saw the parent thread finish a turn, if it did. */
|
|
24
32
|
finishedAt?: number;
|
|
33
|
+
/**
|
|
34
|
+
* When a window last saw the parent thread go from busy to idle, if one did;
|
|
35
|
+
* null while that is not yet known, and no failure under it counts.
|
|
36
|
+
*/
|
|
37
|
+
idleSince?: number | null;
|
|
25
38
|
}
|
|
26
39
|
|
|
27
40
|
/** A parent thread is idle when it is not working, setting up, running background work or holding a queued message. */
|
|
@@ -41,21 +54,72 @@ export function failureTime(
|
|
|
41
54
|
}
|
|
42
55
|
|
|
43
56
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
57
|
+
* When a child's failure becomes an orphaned failure, or null while it cannot:
|
|
58
|
+
* the parent thread is idle and has not run or finished since the child
|
|
59
|
+
* failed, and has stayed idle for `ORPHAN_WAIT_MS` from the later of the
|
|
60
|
+
* failure and its last becoming idle. A busy parent thread, or one that
|
|
46
61
|
* finished after the failure, is taken to know about it. The parent thread's
|
|
47
62
|
* own clock is its last finish (`finishedAt`, or `latestAttentionAt` when it
|
|
48
63
|
* failed): `updatedAt` also moves when someone merely opens or renames it.
|
|
49
64
|
*/
|
|
65
|
+
export function orphanedAt(
|
|
66
|
+
thread: Pick<PluginSidebarThread, "latestAttentionAt" | "updatedAt">,
|
|
67
|
+
flags: ReadonlySet<Flag>,
|
|
68
|
+
parent: ParentThread,
|
|
69
|
+
): number | null {
|
|
70
|
+
if (!flags.has("unread-failed") && !flags.has("queue-failed")) return null;
|
|
71
|
+
if (!isParentIdle(parent) || parent.idleSince === null) return null;
|
|
72
|
+
const parentActiveAt = Math.max(parent.thread.latestAttentionAt, parent.finishedAt ?? 0);
|
|
73
|
+
const failedAt = failureTime(thread, flags);
|
|
74
|
+
if (parentActiveAt > failedAt) return null;
|
|
75
|
+
return Math.max(failedAt, parent.idleSince ?? 0) + ORPHAN_WAIT_MS;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Whether a child's failure is an orphaned failure at `now` (see `orphanedAt`). */
|
|
50
79
|
export function isOrphanedFailure(
|
|
51
80
|
thread: Pick<PluginSidebarThread, "latestAttentionAt" | "updatedAt">,
|
|
52
81
|
flags: ReadonlySet<Flag>,
|
|
53
82
|
parent: ParentThread,
|
|
83
|
+
now: number,
|
|
54
84
|
): boolean {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
85
|
+
const at = orphanedAt(thread, flags, parent);
|
|
86
|
+
return at !== null && at <= now;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Which threads the list last saw busy, and when each last went from busy to idle. */
|
|
90
|
+
export interface IdleTracker {
|
|
91
|
+
busy: ReadonlySet<string>;
|
|
92
|
+
idleSince: Readonly<Record<string, number>>;
|
|
93
|
+
/** The threads this step saw go idle, for the server's `idleAt`. */
|
|
94
|
+
wentIdle: readonly string[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isBusyThread(thread: PluginSidebarThread): boolean {
|
|
98
|
+
const state = computeState(thread, { unread: false, hasDraft: false, scheduledAt: null, now: 0 });
|
|
99
|
+
return !isParentIdle({ thread, state });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The tracker after the list sees `threads` at `at`: a thread busy before and
|
|
104
|
+
* idle now went idle at `at`. A thread first seen idle has no time here; the
|
|
105
|
+
* server's `idleAt` holds what an earlier window saw.
|
|
106
|
+
*/
|
|
107
|
+
export function trackIdle(previous: IdleTracker | null, threads: readonly PluginSidebarThread[], at: number): IdleTracker {
|
|
108
|
+
const busy = new Set<string>();
|
|
109
|
+
const idleSince: Record<string, number> = {};
|
|
110
|
+
const wentIdle: string[] = [];
|
|
111
|
+
for (const thread of threads) {
|
|
112
|
+
const id = thread.id;
|
|
113
|
+
if (isBusyThread(thread)) {
|
|
114
|
+
busy.add(id);
|
|
115
|
+
} else if (previous?.busy.has(id)) {
|
|
116
|
+
idleSince[id] = at;
|
|
117
|
+
wentIdle.push(id);
|
|
118
|
+
} else if (previous?.idleSince[id] !== undefined) {
|
|
119
|
+
idleSince[id] = previous.idleSince[id];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { busy, idleSince, wentIdle };
|
|
59
123
|
}
|
|
60
124
|
|
|
61
125
|
/**
|
|
@@ -6,7 +6,7 @@ import type { Forest } from "./trees";
|
|
|
6
6
|
/**
|
|
7
7
|
* `reveal` targets open the parent chips on their path. `open` targets (a
|
|
8
8
|
* thread that newly became unread) open no chip, so finished children don't
|
|
9
|
-
* expand their parent's. The
|
|
9
|
+
* expand their parent's. The focused thread's target, of either kind, also
|
|
10
10
|
* opens its collapsed group. No target opens a settled fold.
|
|
11
11
|
*/
|
|
12
12
|
export type TargetKind = "reveal" | "open";
|
|
@@ -42,7 +42,7 @@ export function lastActivityAt(
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
|
-
* A settled thread: quiet as if no thread were
|
|
45
|
+
* A settled thread: quiet as if no thread were focused, not needing attention,
|
|
46
46
|
* not pinned, and its own last activity is older than the Settle after
|
|
47
47
|
* period. Hidden and archived threads take the same test.
|
|
48
48
|
*/
|
|
@@ -33,8 +33,8 @@ export const FLAG_ORDER: readonly Flag[] = [
|
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* `working` is bb's timeline accent, so a running thread stands out in both
|
|
36
|
-
* themes; `background`
|
|
37
|
-
*
|
|
36
|
+
* themes; `background` draws background activity in the grey `muted` has,
|
|
37
|
+
* and its glyph shines, which keeps it apart from a draft.
|
|
38
38
|
*/
|
|
39
39
|
export type Tone = "attention" | "destructive" | "working" | "background" | "muted" | "muted-strong" | "none";
|
|
40
40
|
|
|
@@ -109,17 +109,30 @@ export function isOffline(thread: Pick<PluginSidebarThread, "status" | "runtimeS
|
|
|
109
109
|
/** Per-thread facts the host payload doesn't carry. */
|
|
110
110
|
export interface ThreadContext {
|
|
111
111
|
activeThreadId: string | null;
|
|
112
|
+
/**
|
|
113
|
+
* Threads shown in a visible pane of the window besides the active one:
|
|
114
|
+
* bb's split panes. Absent, only the active (focused) thread is open.
|
|
115
|
+
*/
|
|
116
|
+
openThreadIds?: ReadonlySet<string>;
|
|
112
117
|
/** Stamps, from the plugin server. */
|
|
113
118
|
finishedAt: Readonly<Record<string, number>>;
|
|
114
119
|
seenAt: Readonly<Record<string, number>>;
|
|
115
120
|
}
|
|
116
121
|
|
|
122
|
+
/** Whether the thread is shown in a visible pane: the active one or another split pane. */
|
|
123
|
+
export function isOpenThread(thread: Pick<PluginSidebarThread, "id">, context: ThreadContext): boolean {
|
|
124
|
+
return thread.id === context.activeThreadId || (context.openThreadIds?.has(thread.id) ?? false);
|
|
125
|
+
}
|
|
126
|
+
|
|
117
127
|
/**
|
|
118
128
|
* bb's rule for every thread: unread when it has finished (idle or
|
|
119
129
|
* error) since it was last read. Children are also unread when they finished
|
|
120
|
-
* after you last looked at them (done-unseen).
|
|
130
|
+
* after you last looked at them (done-unseen). An open thread is never
|
|
131
|
+
* unread: bb marks it read moments after it finishes or fails, and counting
|
|
132
|
+
* it until then only flashes the need-you filter.
|
|
121
133
|
*/
|
|
122
134
|
export function isUnread(thread: PluginSidebarThread, context: ThreadContext): boolean {
|
|
135
|
+
if (isOpenThread(thread, context)) return false;
|
|
123
136
|
const status = normalizeStatus(thread);
|
|
124
137
|
const lastRead = thread.lastReadAt ?? 0;
|
|
125
138
|
if ((status === "idle" || status === "error") && lastRead < thread.latestAttentionAt) {
|
|
@@ -132,7 +145,7 @@ export function isUnread(thread: PluginSidebarThread, context: ThreadContext): b
|
|
|
132
145
|
export function isDoneUnseen(thread: PluginSidebarThread, context: ThreadContext): boolean {
|
|
133
146
|
if (thread.parentThreadId === null) return false;
|
|
134
147
|
if (normalizeStatus(thread) !== "idle") return false;
|
|
135
|
-
if (thread
|
|
148
|
+
if (isOpenThread(thread, context)) return false;
|
|
136
149
|
const finished = context.finishedAt[thread.id];
|
|
137
150
|
if (finished === undefined) return false;
|
|
138
151
|
return finished > Math.max(thread.lastReadAt ?? 0, context.seenAt[thread.id] ?? 0);
|
|
@@ -234,7 +247,7 @@ export function computeState(thread: PluginSidebarThread, inputs: StateInputs):
|
|
|
234
247
|
}
|
|
235
248
|
if (inputs.unread) return { kind: "unread", label: "Unread", glyph: glyph("dot", "none"), ...none };
|
|
236
249
|
if (inputs.hasDraft) return { kind: "draft", label: "Unsubmitted draft", glyph: glyph("Edit", "muted"), ...none };
|
|
237
|
-
return { kind: "idle", label: "Idle", glyph: glyph("ring", "muted"), ...none };
|
|
250
|
+
return { kind: "idle", label: "Idle", glyph: glyph("ring", "muted-strong"), ...none };
|
|
238
251
|
}
|
|
239
252
|
|
|
240
253
|
/**
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import type { Preferences } from "@/shared/preferences";
|
|
4
4
|
import { ancestorsOf, type ThreadTree, type Forest } from "./trees";
|
|
5
5
|
import { isGroupCollapsed, toggleGroupCollapse } from "./groups";
|
|
6
|
-
import { isDoneUnseen } from "./state";
|
|
6
|
+
import { isDoneUnseen, type ThreadContext } from "./state";
|
|
7
7
|
import type { GroupView, OlderRow, SettledRow, ThreadRow } from "./view";
|
|
8
8
|
|
|
9
9
|
export interface ToggleOutcome {
|
|
@@ -76,7 +76,7 @@ export interface MarkAllRead {
|
|
|
76
76
|
/** Every unread thread in the trees, descendants included: Mark all read, and a root's Mark read for its tree. */
|
|
77
77
|
export function markAllReadPlan(
|
|
78
78
|
trees: readonly ThreadTree[],
|
|
79
|
-
context:
|
|
79
|
+
context: ThreadContext,
|
|
80
80
|
): MarkAllRead {
|
|
81
81
|
const read: string[] = [];
|
|
82
82
|
const seen: string[] = [];
|
|
@@ -97,7 +97,7 @@ export function markAllReadPlan(
|
|
|
97
97
|
export function markReadPlanFor(
|
|
98
98
|
threadId: string,
|
|
99
99
|
forest: Pick<Forest, "infos" | "treeOf">,
|
|
100
|
-
context:
|
|
100
|
+
context: ThreadContext,
|
|
101
101
|
): MarkAllRead {
|
|
102
102
|
const tree = forest.treeOf.get(threadId);
|
|
103
103
|
if (tree !== undefined && tree.root.thread.id === threadId) return markAllReadPlan([tree], context);
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
type StateKind,
|
|
14
14
|
type ThreadState,
|
|
15
15
|
} from "./state";
|
|
16
|
-
import { attentionFlagsOf,
|
|
16
|
+
import { attentionFlagsOf, orphanedAt } from "./attention";
|
|
17
17
|
import { compareCreationAscending } from "./sort";
|
|
18
18
|
import { needsKindOf, rowNote, type RowNote } from "./notes";
|
|
19
19
|
import type { ThreadNotes } from "@/shared/contract";
|
|
@@ -40,9 +40,9 @@ export interface ThreadInfo {
|
|
|
40
40
|
/** Quiet test for the thread alone. */
|
|
41
41
|
quiet: boolean;
|
|
42
42
|
/**
|
|
43
|
-
* A quiet thread, as if no thread were
|
|
43
|
+
* A quiet thread, as if no thread were focused: nothing to see behind a
|
|
44
44
|
* tree's fold. A root, or any child when `childAttention` is
|
|
45
|
-
* `everything`, takes the quiet test without the
|
|
45
|
+
* `everything`, takes the quiet test without the focused thread's exemption.
|
|
46
46
|
* Otherwise a child is quiet unless it runs or needs attention, so finishing
|
|
47
47
|
* unread folds it; an archived child is always quiet. The fold reads this,
|
|
48
48
|
* so opening a thread never changes which children stay shown.
|
|
@@ -67,7 +67,7 @@ export interface Subtree {
|
|
|
67
67
|
visibleCount: number;
|
|
68
68
|
/** Visible direct children: the chip's number. */
|
|
69
69
|
childCount: number;
|
|
70
|
-
/** Every visible descendant is quiet, as if no thread were
|
|
70
|
+
/** Every visible descendant is quiet, as if no thread were focused, so the subtree adds nothing to see when folded. */
|
|
71
71
|
quietIgnoringOpen: boolean;
|
|
72
72
|
}
|
|
73
73
|
|
|
@@ -88,7 +88,7 @@ export interface ThreadTree {
|
|
|
88
88
|
/** Every thread quiet and the active thread not in it. */
|
|
89
89
|
quiet: boolean;
|
|
90
90
|
/**
|
|
91
|
-
* The quiet test without the
|
|
91
|
+
* The quiet test without the focused thread's exemption. A group's fold reads
|
|
92
92
|
* this, so opening a thread never changes which roots stay shown.
|
|
93
93
|
*/
|
|
94
94
|
quietIgnoringOpen: boolean;
|
|
@@ -103,6 +103,8 @@ export interface Forest {
|
|
|
103
103
|
treeOf: ReadonlyMap<string, ThreadTree>;
|
|
104
104
|
/** The rollup under every thread with a row, by thread id. */
|
|
105
105
|
subtrees: ReadonlyMap<string, Subtree>;
|
|
106
|
+
/** The earliest moment after `now` a child's failure becomes an orphaned failure, or null for none. */
|
|
107
|
+
nextOrphanAt: number | null;
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
export interface ForestInputs extends ThreadContext {
|
|
@@ -114,6 +116,12 @@ export interface ForestInputs extends ThreadContext {
|
|
|
114
116
|
notes?: Readonly<Record<string, ThreadNotes>>;
|
|
115
117
|
/** Which children can need attention; `blocked` when absent. */
|
|
116
118
|
childAttention?: ChildAttention;
|
|
119
|
+
/** When this list last saw each thread go from busy to idle (see `trackIdle`). */
|
|
120
|
+
idleSince?: Readonly<Record<string, number>>;
|
|
121
|
+
/** The server's `idleAt` stamps: when any window last saw each thread go idle. */
|
|
122
|
+
idleAt?: Readonly<Record<string, number>>;
|
|
123
|
+
/** False until the stamps have loaded: no failure counts as orphaned before then. */
|
|
124
|
+
stampsLoaded?: boolean;
|
|
117
125
|
}
|
|
118
126
|
|
|
119
127
|
function isPinned(thread: PluginSidebarThread): boolean {
|
|
@@ -148,6 +156,18 @@ export function attachParent(
|
|
|
148
156
|
return attach;
|
|
149
157
|
}
|
|
150
158
|
|
|
159
|
+
/**
|
|
160
|
+
* When a thread last went idle, as this list or any window saw it; null while
|
|
161
|
+
* the stamps that may hold it have not loaded.
|
|
162
|
+
*/
|
|
163
|
+
function idleSinceOf(id: string, inputs: ForestInputs): number | null | undefined {
|
|
164
|
+
if (inputs.stampsLoaded === false) return null;
|
|
165
|
+
const seen = inputs.idleSince?.[id];
|
|
166
|
+
const stamped = inputs.idleAt?.[id];
|
|
167
|
+
if (seen === undefined) return stamped;
|
|
168
|
+
return stamped === undefined ? seen : Math.max(seen, stamped);
|
|
169
|
+
}
|
|
170
|
+
|
|
151
171
|
export function buildForest(inputs: ForestInputs): Forest {
|
|
152
172
|
const byId = new Map(inputs.threads.map((thread) => [thread.id, thread]));
|
|
153
173
|
const infos = new Map<string, ThreadInfo>();
|
|
@@ -178,14 +198,22 @@ export function buildForest(inputs: ForestInputs): Forest {
|
|
|
178
198
|
}
|
|
179
199
|
|
|
180
200
|
const mode = inputs.childAttention ?? "blocked";
|
|
201
|
+
let nextOrphanAt: number | null = null;
|
|
181
202
|
for (const info of infos.values()) {
|
|
182
203
|
const parent = info.parentId === null ? undefined : infos.get(info.parentId);
|
|
204
|
+
const orphaned =
|
|
205
|
+
parent === undefined
|
|
206
|
+
? null
|
|
207
|
+
: orphanedAt(info.thread, info.flags, {
|
|
208
|
+
...parent,
|
|
209
|
+
finishedAt: inputs.finishedAt[parent.thread.id],
|
|
210
|
+
idleSince: idleSinceOf(parent.thread.id, inputs),
|
|
211
|
+
});
|
|
212
|
+
if (orphaned !== null && orphaned > inputs.now) nextOrphanAt = Math.min(nextOrphanAt ?? orphaned, orphaned);
|
|
183
213
|
info.attentionFlags = attentionFlagsOf(info.flags, {
|
|
184
214
|
isRoot: parent === undefined,
|
|
185
215
|
mode,
|
|
186
|
-
orphaned:
|
|
187
|
-
parent !== undefined &&
|
|
188
|
-
isOrphanedFailure(info.thread, info.flags, { ...parent, finishedAt: inputs.finishedAt[parent.thread.id] }),
|
|
216
|
+
orphaned: orphaned !== null && orphaned <= inputs.now,
|
|
189
217
|
});
|
|
190
218
|
info.quietIgnoringOpen =
|
|
191
219
|
parent === undefined || mode === "everything"
|
|
@@ -294,7 +322,16 @@ export function buildForest(inputs: ForestInputs): Forest {
|
|
|
294
322
|
treeOf.set(root.thread.id, tree);
|
|
295
323
|
for (const info of descendants) treeOf.set(info.thread.id, tree);
|
|
296
324
|
}
|
|
297
|
-
return { infos, children, trees, treeOf, subtrees };
|
|
325
|
+
return { infos, children, trees, treeOf, subtrees, nextOrphanAt };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Whether any thread in the trees is unread, descendants, hidden and
|
|
330
|
+
* archived threads included: the threads Mark all read marks, so it is
|
|
331
|
+
* offered only when this holds.
|
|
332
|
+
*/
|
|
333
|
+
export function anyUnread(trees: readonly ThreadTree[]): boolean {
|
|
334
|
+
return trees.some((tree) => tree.root.unread || tree.descendants.some((info) => info.unread));
|
|
298
335
|
}
|
|
299
336
|
|
|
300
337
|
/** Ids from `id` up to (not including) `stopAt`, nearest first. */
|
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
PluginSidebarThread,
|
|
7
7
|
} from "@get-bb/plugin-sdk/app";
|
|
8
8
|
import type { Preferences } from "@/shared/preferences";
|
|
9
|
-
import { ancestorsOf, type ThreadTree, type Forest, type Subtree, type ThreadInfo } from "./trees";
|
|
9
|
+
import { ancestorsOf, anyUnread, type ThreadTree, type Forest, type Subtree, type ThreadInfo } from "./trees";
|
|
10
10
|
import {
|
|
11
11
|
builtinGroup,
|
|
12
12
|
entityGroups,
|
|
@@ -113,7 +113,7 @@ export interface EnvironmentRow {
|
|
|
113
113
|
depth: number;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
/** A group's settled fold: the
|
|
116
|
+
/** A group's settled fold: the muted "Settled (N)" divider at its end. */
|
|
117
117
|
export interface SettledRow {
|
|
118
118
|
type: "settled";
|
|
119
119
|
key: string;
|
|
@@ -142,6 +142,8 @@ export interface GroupView {
|
|
|
142
142
|
rows: Row[];
|
|
143
143
|
/** Every tree root bucketed in the group, those behind folds included. */
|
|
144
144
|
rootIds: string[];
|
|
145
|
+
/** A thread in the group's trees is unread: its menu offers Mark all read. */
|
|
146
|
+
hasUnread: boolean;
|
|
145
147
|
}
|
|
146
148
|
|
|
147
149
|
export interface ListView {
|
|
@@ -153,6 +155,8 @@ export interface ListView {
|
|
|
153
155
|
order: string[];
|
|
154
156
|
/** Thread trees that need attention, in every group, hidden ones included: the need-you filter's N. */
|
|
155
157
|
needYouCount: number;
|
|
158
|
+
/** A thread in the list is unread, hidden groups included: the list header offers Mark all read. */
|
|
159
|
+
hasUnread: boolean;
|
|
156
160
|
}
|
|
157
161
|
|
|
158
162
|
export interface ViewInputs {
|
|
@@ -188,7 +192,7 @@ interface Context extends ViewInputs {
|
|
|
188
192
|
/** Parent ids on the path to a reveal target. */
|
|
189
193
|
revealPath: ReadonlySet<string>;
|
|
190
194
|
revealIds: ReadonlySet<string>;
|
|
191
|
-
/** The
|
|
195
|
+
/** The focused thread and its ancestors. */
|
|
192
196
|
activePath: ReadonlySet<string>;
|
|
193
197
|
projectNames: ReadonlyMap<string, string>;
|
|
194
198
|
sectionNames: ReadonlyMap<string, string>;
|
|
@@ -325,7 +329,7 @@ function foldedChildren(
|
|
|
325
329
|
}
|
|
326
330
|
|
|
327
331
|
// The stable set reads `quietIgnoringOpen` for the child and everything under it, and
|
|
328
|
-
// ignores which thread is
|
|
332
|
+
// ignores which thread is focused and the reveal targets: they join afterwards
|
|
329
333
|
// and take no other row's place.
|
|
330
334
|
const quietTree = (kid: ThreadInfo) => kid.quietIgnoringOpen && subtreeOf(context, kid.thread.id).quietIgnoringOpen;
|
|
331
335
|
const stable = new Set<string>();
|
|
@@ -514,6 +518,7 @@ function buildGroup(context: Context, descriptor: GroupDescriptor, trees: Thread
|
|
|
514
518
|
hidden,
|
|
515
519
|
rows,
|
|
516
520
|
rootIds: trees.map((tree) => tree.root.thread.id),
|
|
521
|
+
hasUnread: anyUnread(trees),
|
|
517
522
|
};
|
|
518
523
|
}
|
|
519
524
|
|
|
@@ -539,6 +544,7 @@ function needYouGroup(context: Context, descriptor: GroupDescriptor, trees: Thre
|
|
|
539
544
|
hidden,
|
|
540
545
|
rows: clusterEnvironments(context, sorted.filter(needsAttention).map((tree) => treeUnit(context, tree)), 0),
|
|
541
546
|
rootIds: trees.map((tree) => tree.root.thread.id),
|
|
547
|
+
hasUnread: anyUnread(trees),
|
|
542
548
|
};
|
|
543
549
|
}
|
|
544
550
|
|
|
@@ -624,7 +630,7 @@ export function buildListView(inputs: ViewInputs): ListView {
|
|
|
624
630
|
}
|
|
625
631
|
}
|
|
626
632
|
|
|
627
|
-
return { groups, more, moreCounters, order, needYouCount: countNeedYou(forest) };
|
|
633
|
+
return { groups, more, moreCounters, order, needYouCount: countNeedYou(forest), hasUnread: anyUnread(forest.trees) };
|
|
628
634
|
}
|
|
629
635
|
|
|
630
636
|
/** Every thread row in visual order, for keyboard and windowing. */
|
|
@@ -11,3 +11,10 @@
|
|
|
11
11
|
[data-icon] {
|
|
12
12
|
display: inline-block;
|
|
13
13
|
}
|
|
14
|
+
|
|
15
|
+
/* bb's colour utilities read its palette variables; a test sets the ones it needs. */
|
|
16
|
+
@theme inline {
|
|
17
|
+
--color-sidebar: var(--sidebar);
|
|
18
|
+
--color-foreground: var(--foreground);
|
|
19
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
20
|
+
}
|
|
@@ -109,6 +109,8 @@ export interface Scenario {
|
|
|
109
109
|
sections?: PluginSidebarSection[];
|
|
110
110
|
prefs?: Partial<Preferences>;
|
|
111
111
|
activeThreadId?: string | null;
|
|
112
|
+
/** Threads other split panes show. */
|
|
113
|
+
openThreadIds?: string[];
|
|
112
114
|
targets?: Targets;
|
|
113
115
|
finishedAt?: Record<string, number>;
|
|
114
116
|
seenAt?: Record<string, number>;
|
|
@@ -117,6 +119,10 @@ export interface Scenario {
|
|
|
117
119
|
notes?: Record<string, import("@/shared/contract").ThreadNotes>;
|
|
118
120
|
now?: number;
|
|
119
121
|
startedAt?: Record<string, number>;
|
|
122
|
+
/** When this list saw each thread go idle, and the server's idleAt stamps. */
|
|
123
|
+
idleSince?: Record<string, number>;
|
|
124
|
+
idleAt?: Record<string, number>;
|
|
125
|
+
stampsLoaded?: boolean;
|
|
120
126
|
/** Project id → default branch; "main" for every project when absent. */
|
|
121
127
|
defaultBranches?: Record<string, string | null>;
|
|
122
128
|
/** bb's primary machine; "host_1", the fixtures' own, when absent. */
|
|
@@ -131,6 +137,7 @@ export function forestOf(scenario: Scenario): Forest {
|
|
|
131
137
|
return buildForest({
|
|
132
138
|
threads: scenario.threads,
|
|
133
139
|
activeThreadId: scenario.activeThreadId ?? null,
|
|
140
|
+
openThreadIds: new Set(scenario.openThreadIds ?? []),
|
|
134
141
|
finishedAt: scenario.finishedAt ?? {},
|
|
135
142
|
seenAt: scenario.seenAt ?? {},
|
|
136
143
|
scheduled: scenario.scheduled ?? {},
|
|
@@ -138,6 +145,9 @@ export function forestOf(scenario: Scenario): Forest {
|
|
|
138
145
|
notes: scenario.notes ?? {},
|
|
139
146
|
childAttention: scenario.prefs?.childAttention,
|
|
140
147
|
now: scenario.now ?? T0 + 60_000,
|
|
148
|
+
idleSince: scenario.idleSince,
|
|
149
|
+
idleAt: scenario.idleAt,
|
|
150
|
+
stampsLoaded: scenario.stampsLoaded,
|
|
141
151
|
});
|
|
142
152
|
}
|
|
143
153
|
|
package/package.json
CHANGED
package/server/stamps.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// Per-thread timestamps: one kv row per thread, `stamp:<threadId>`,
|
|
2
|
-
// holding whichever of the
|
|
2
|
+
// holding whichever of the kinds it has. Rows are read into memory once
|
|
3
3
|
// and written through, so listing does not read every row per call.
|
|
4
4
|
import type { BbPluginApi } from "@get-bb/plugin-sdk";
|
|
5
5
|
import { CHANNELS, type StampKind, type StampSignal, type Stamps } from "../shared/contract";
|
|
6
6
|
import { createSerialQueue } from "./serial";
|
|
7
7
|
|
|
8
8
|
export const STAMP_KEY_PREFIX = "stamp:";
|
|
9
|
-
export const STAMP_KINDS: readonly StampKind[] = ["startedAt", "finishedAt", "pendingAt", "seenAt"];
|
|
9
|
+
export const STAMP_KINDS: readonly StampKind[] = ["startedAt", "finishedAt", "pendingAt", "seenAt", "idleAt"];
|
|
10
10
|
|
|
11
11
|
export type ThreadStamps = Partial<Record<StampKind, number>>;
|
|
12
12
|
|
|
@@ -30,6 +30,12 @@ export interface StampStore {
|
|
|
30
30
|
list(): Promise<Stamps>;
|
|
31
31
|
/** Sets `kind` to `at` for each thread and publishes one signal. */
|
|
32
32
|
stamp(kind: StampKind, threadIds: readonly string[], at: number): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Sets `kind` to `at` for each thread whose stored value is earlier or
|
|
35
|
+
* absent, and publishes one signal for those. A later value is kept, so
|
|
36
|
+
* several windows reporting one moment cannot move it back.
|
|
37
|
+
*/
|
|
38
|
+
advance(kind: StampKind, threadIds: readonly string[], at: number): Promise<void>;
|
|
33
39
|
/** Deletes `kind` for each thread and publishes one signal with `value: null`. */
|
|
34
40
|
clear(kind: StampKind, threadIds: readonly string[]): Promise<void>;
|
|
35
41
|
/** Deletes every stamp of each thread. Publishes nothing. */
|
|
@@ -89,7 +95,7 @@ export function createStampStore(
|
|
|
89
95
|
return {
|
|
90
96
|
list: () =>
|
|
91
97
|
serial(async () => {
|
|
92
|
-
const result: Stamps = { startedAt: {}, finishedAt: {}, pendingAt: {}, seenAt: {} };
|
|
98
|
+
const result: Stamps = { startedAt: {}, finishedAt: {}, pendingAt: {}, seenAt: {}, idleAt: {} };
|
|
93
99
|
for (const [threadId, stamps] of await load()) {
|
|
94
100
|
for (const kind of STAMP_KINDS) {
|
|
95
101
|
const value = stamps[kind];
|
|
@@ -107,6 +113,18 @@ export function createStampStore(
|
|
|
107
113
|
}
|
|
108
114
|
publish({ kind, threadIds: [...threadIds], value: at });
|
|
109
115
|
}),
|
|
116
|
+
advance: (kind, threadIds, at) =>
|
|
117
|
+
serial(async () => {
|
|
118
|
+
const rows = await load();
|
|
119
|
+
const moved: string[] = [];
|
|
120
|
+
for (const threadId of new Set(threadIds)) {
|
|
121
|
+
const current = rows.get(threadId);
|
|
122
|
+
if ((current?.[kind] ?? -Infinity) >= at) continue;
|
|
123
|
+
await save(rows, threadId, { ...current, [kind]: at });
|
|
124
|
+
moved.push(threadId);
|
|
125
|
+
}
|
|
126
|
+
if (moved.length > 0) publish({ kind, threadIds: moved, value: at });
|
|
127
|
+
}),
|
|
110
128
|
clear: (kind, threadIds) =>
|
|
111
129
|
serial(async () => {
|
|
112
130
|
if (threadIds.length === 0) return;
|
package/server.ts
CHANGED
|
@@ -51,6 +51,11 @@ export default function threadGlance(bb: BbPluginApi): void {
|
|
|
51
51
|
await stamps.stamp("seenAt", threadIds, at);
|
|
52
52
|
return { at };
|
|
53
53
|
},
|
|
54
|
+
async markIdle({ threadIds }) {
|
|
55
|
+
const at = Date.now();
|
|
56
|
+
await stamps.advance("idleAt", threadIds, at);
|
|
57
|
+
return { at };
|
|
58
|
+
},
|
|
54
59
|
async clearSeen({ threadIds }) {
|
|
55
60
|
await stamps.clear("seenAt", threadIds);
|
|
56
61
|
return { ok: true as const };
|
package/shared/contract.ts
CHANGED
|
@@ -56,6 +56,14 @@ export const rpcContract = defineRpcContract({
|
|
|
56
56
|
input: z.object({ threadIds: threadIdsSchema }).strict(),
|
|
57
57
|
output: z.object({ ok: z.literal(true) }).strict(),
|
|
58
58
|
},
|
|
59
|
+
/**
|
|
60
|
+
* Records `idleAt` for threads a window saw go from busy to idle. A thread
|
|
61
|
+
* keeps the later of its stored moment and this one.
|
|
62
|
+
*/
|
|
63
|
+
markIdle: {
|
|
64
|
+
input: z.object({ threadIds: threadIdsSchema }).strict(),
|
|
65
|
+
output: z.object({ at: z.number() }).strict(),
|
|
66
|
+
},
|
|
59
67
|
/** Notes per thread id (see `noteSchema`). */
|
|
60
68
|
listNotes: {
|
|
61
69
|
input: z.null(),
|
package/shared/signals.ts
CHANGED
|
@@ -11,6 +11,8 @@ export const stampsSchema = z.object({
|
|
|
11
11
|
finishedAt: stampMapSchema,
|
|
12
12
|
pendingAt: stampMapSchema,
|
|
13
13
|
seenAt: stampMapSchema,
|
|
14
|
+
/** When a window saw the thread go from busy to idle, the latest such moment. */
|
|
15
|
+
idleAt: stampMapSchema,
|
|
14
16
|
});
|
|
15
17
|
export type Stamps = z.infer<typeof stampsSchema>;
|
|
16
18
|
export type StampKind = keyof Stamps;
|
|
@@ -56,7 +56,7 @@ and `bb environment list`; a thread id is a `thr_…` id.
|
|
|
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 | `[]` |
|
|
59
|
-
| `childAttention` | Needs attention counts every child. Which child threads need attention (collapsed groups, the need-you filter, counters, auto-reveal) and stay out of a tree's "N more child threads" fold alongside running ones: `blocked` counts a child that waits on you, is offline, or has an orphaned failure (its parent thread idle since); `everything` also counts every failed or unread child | `blocked` |
|
|
59
|
+
| `childAttention` | Needs attention counts every child. Which child threads need attention (collapsed groups, the need-you filter, counters, auto-reveal) and stay out of a tree's "N more child threads" fold alongside running ones: `blocked` counts a child that waits on you, is offline, or has an orphaned failure (its parent thread idle for 5 seconds since it failed); `everything` also counts every failed or unread child | `blocked` |
|
|
60
60
|
| `harnessIcon` | How rows that draw a harness logo draw it: `muted` (monochrome) or `colour` (the provider's tint). A row draws one only where its harness differs from bb's default (a root) or its parent thread's (a child) | `muted` |
|
|
61
61
|
|
|
62
62
|
Row density and the need-you filter are kept per browser window, and the CLI
|