@gperezmz/bb-plugin-thread-glance 0.4.0 → 0.4.2
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 +25 -25
- 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/GroupSection.tsx +10 -7
- package/features/thread-list/components/ListHeader.tsx +8 -3
- package/features/thread-list/components/ThreadList.tsx +36 -24
- package/features/thread-list/components/ThreadRowView.tsx +5 -4
- package/features/thread-list/components/controller.ts +1 -1
- 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 +5 -67
- package/features/thread-list/model/state.ts +15 -2
- 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 +10 -4
- package/features/thread-list/testing/fixtures.ts +11 -7
- 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 +2 -2
- package/features/thread-list/components/PullRequestProbes.tsx +0 -27
- package/features/thread-list/data/usePullRequestAnswers.ts +0 -36
|
@@ -15,46 +15,12 @@ export const SETTLE_AFTER_MS: Readonly<Record<Exclude<SettleAfter, "never">, num
|
|
|
15
15
|
"1w": 7 * 24 * HOUR,
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
-
/** A pull request's state, as bb's lookup reports it. */
|
|
19
|
-
export type PullRequestState = "open" | "draft" | "merged" | "closed";
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* What settling knows of a thread's pull request: its state, null when the
|
|
23
|
-
* thread has none, or `unknown` while a lookup it needs has not answered.
|
|
24
|
-
*/
|
|
25
|
-
export type PullRequestFact = PullRequestState | null | "unknown";
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Whether a thread's branch can carry a pull request worth looking up: it is
|
|
29
|
-
* on a branch other than its project's default. A default branch the lookup
|
|
30
|
-
* could not find (null) is no reason to skip one; one still being looked up
|
|
31
|
-
* (undefined) is, until it answers.
|
|
32
|
-
*/
|
|
33
|
-
export function needsPullRequestLookup(branch: string | null, defaultBranch: string | null | undefined): boolean {
|
|
34
|
-
return branch !== null && defaultBranch !== undefined && branch !== defaultBranch;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** A thread's pull request fact, from its project's default branch and the lookups that answered. */
|
|
38
|
-
export function pullRequestFact(
|
|
39
|
-
thread: Pick<PluginSidebarThread, "id" | "environment">,
|
|
40
|
-
defaultBranch: string | null | undefined,
|
|
41
|
-
answers: ReadonlyMap<string, PullRequestState | null>,
|
|
42
|
-
): PullRequestFact {
|
|
43
|
-
const branch = thread.environment?.branchName ?? null;
|
|
44
|
-
if (branch === null) return null;
|
|
45
|
-
if (defaultBranch === undefined) return "unknown";
|
|
46
|
-
if (!needsPullRequestLookup(branch, defaultBranch)) return null;
|
|
47
|
-
const answer = answers.get(thread.id);
|
|
48
|
-
return answer === undefined ? "unknown" : answer;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
18
|
export interface SettleInputs {
|
|
52
19
|
now: number;
|
|
53
20
|
settleAfter: SettleAfter;
|
|
54
21
|
/** Server stamps: when each thread last started and finished a turn. */
|
|
55
22
|
startedAt: Readonly<Record<string, number>>;
|
|
56
23
|
finishedAt: Readonly<Record<string, number>>;
|
|
57
|
-
pullRequestOf(thread: PluginSidebarThread): PullRequestFact;
|
|
58
24
|
}
|
|
59
25
|
|
|
60
26
|
/**
|
|
@@ -76,47 +42,19 @@ export function lastActivityAt(
|
|
|
76
42
|
}
|
|
77
43
|
|
|
78
44
|
/**
|
|
79
|
-
* A settled thread: quiet as if no thread were
|
|
80
|
-
* not pinned,
|
|
81
|
-
*
|
|
82
|
-
* Hidden and archived threads take the same test.
|
|
45
|
+
* A settled thread: quiet as if no thread were focused, not needing attention,
|
|
46
|
+
* not pinned, and its own last activity is older than the Settle after
|
|
47
|
+
* period. Hidden and archived threads take the same test.
|
|
83
48
|
*/
|
|
84
49
|
export function isSettledThread(info: ThreadInfo, inputs: SettleInputs): boolean {
|
|
85
50
|
const thread = info.thread;
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
if (pullRequest === "unknown" || pullRequest === "open" || pullRequest === "draft") return false;
|
|
89
|
-
if (pullRequest === "merged" || pullRequest === "closed") return true;
|
|
51
|
+
if (thread.pinnedAt !== null || thread.isPinned) return false;
|
|
52
|
+
if (!isQuietThread(info.state, info.unread, false) || info.attentionFlags.size > 0) return false;
|
|
90
53
|
if (inputs.settleAfter === "never") return false;
|
|
91
54
|
return inputs.now - lastActivityAt(thread, inputs) >= SETTLE_AFTER_MS[inputs.settleAfter];
|
|
92
55
|
}
|
|
93
56
|
|
|
94
|
-
/** A row's thread that settles once its pull request and activity allow: quiet as if none were open, needing no attention, not pinned. */
|
|
95
|
-
function mightSettle(info: ThreadInfo): boolean {
|
|
96
|
-
const thread = info.thread;
|
|
97
|
-
if (thread.pinnedAt !== null || thread.isPinned) return false;
|
|
98
|
-
return isQuietThread(info.state, info.unread, false) && info.attentionFlags.size === 0;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
57
|
/** A tree settles as one unit: when every thread in it is settled. */
|
|
102
58
|
export function isSettledTree(tree: Pick<ThreadTree, "root" | "descendants">, inputs: SettleInputs): boolean {
|
|
103
59
|
return isSettledThread(tree.root, inputs) && tree.descendants.every((info) => isSettledThread(info, inputs));
|
|
104
60
|
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* The threads whose pull request settling has to look up: every thread that
|
|
108
|
-
* would settle but for its pull request, on a branch other than its
|
|
109
|
-
* project's default.
|
|
110
|
-
*/
|
|
111
|
-
export function pullRequestLookupIds(
|
|
112
|
-
infos: Iterable<ThreadInfo>,
|
|
113
|
-
defaultBranchOf: (thread: PluginSidebarThread) => string | null | undefined,
|
|
114
|
-
): string[] {
|
|
115
|
-
const ids: string[] = [];
|
|
116
|
-
for (const info of infos) {
|
|
117
|
-
const thread = info.thread;
|
|
118
|
-
if (!mightSettle(info)) continue;
|
|
119
|
-
if (needsPullRequestLookup(thread.environment?.branchName ?? null, defaultBranchOf(thread))) ids.push(thread.id);
|
|
120
|
-
}
|
|
121
|
-
return ids;
|
|
122
|
-
}
|
|
@@ -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);
|
|
@@ -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,
|
|
@@ -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. */
|
|
@@ -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
|
|
|
@@ -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. */
|
|
@@ -125,14 +131,13 @@ export interface Scenario {
|
|
|
125
131
|
needYouOnly?: boolean;
|
|
126
132
|
/** bb's default harness; "claude-code", the fixtures' own, when absent. */
|
|
127
133
|
defaultProviderId?: string | null;
|
|
128
|
-
/** Pull request lookups that answered, by thread id. */
|
|
129
|
-
pullRequests?: Record<string, PullRequestState | null>;
|
|
130
134
|
}
|
|
131
135
|
|
|
132
136
|
export function forestOf(scenario: Scenario): Forest {
|
|
133
137
|
return buildForest({
|
|
134
138
|
threads: scenario.threads,
|
|
135
139
|
activeThreadId: scenario.activeThreadId ?? null,
|
|
140
|
+
openThreadIds: new Set(scenario.openThreadIds ?? []),
|
|
136
141
|
finishedAt: scenario.finishedAt ?? {},
|
|
137
142
|
seenAt: scenario.seenAt ?? {},
|
|
138
143
|
scheduled: scenario.scheduled ?? {},
|
|
@@ -140,6 +145,9 @@ export function forestOf(scenario: Scenario): Forest {
|
|
|
140
145
|
notes: scenario.notes ?? {},
|
|
141
146
|
childAttention: scenario.prefs?.childAttention,
|
|
142
147
|
now: scenario.now ?? T0 + 60_000,
|
|
148
|
+
idleSince: scenario.idleSince,
|
|
149
|
+
idleAt: scenario.idleAt,
|
|
150
|
+
stampsLoaded: scenario.stampsLoaded,
|
|
143
151
|
});
|
|
144
152
|
}
|
|
145
153
|
|
|
@@ -165,15 +173,11 @@ export function viewOf(scenario: Scenario): ListView {
|
|
|
165
173
|
|
|
166
174
|
/** The settle inputs a scenario stands for. */
|
|
167
175
|
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
176
|
return {
|
|
172
177
|
now: scenario.now ?? T0 + 60_000,
|
|
173
178
|
settleAfter: scenario.prefs?.settleAfter ?? defaultPreferences().settleAfter,
|
|
174
179
|
startedAt: scenario.startedAt ?? {},
|
|
175
180
|
finishedAt: scenario.finishedAt ?? {},
|
|
176
|
-
pullRequestOf: (thread) => pullRequestFact(thread, defaultBranchOf(thread.projectId), answers),
|
|
177
181
|
};
|
|
178
182
|
}
|
|
179
183
|
|
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;
|
|
@@ -52,11 +52,11 @@ 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 | `[]` |
|
|
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
|
|
@@ -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
|
-
}
|