@gperezmz/bb-plugin-thread-glance 0.2.4 → 0.3.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/README.md +1 -1
- package/app.tsx +3 -11
- package/dist/app.css +1 -1
- package/dist/app.js +31 -31
- 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 +21 -30
- package/features/thread-list/components/GroupSection.tsx +7 -45
- package/features/thread-list/components/ListHeader.tsx +79 -0
- package/features/thread-list/components/ProviderBadge.tsx +0 -1
- package/features/thread-list/components/PullRequestProbes.tsx +27 -0
- package/features/thread-list/components/SettingsPanel.tsx +20 -34
- package/features/thread-list/components/ThreadDetails.tsx +3 -3
- package/features/thread-list/components/ThreadList.tsx +117 -83
- package/features/thread-list/components/ThreadRowView.tsx +70 -78
- package/features/thread-list/components/controller.ts +5 -10
- package/features/thread-list/components/glyphs.tsx +26 -13
- package/features/thread-list/data/useAutoExpand.ts +1 -1
- package/features/thread-list/data/useDefaultBranches.ts +35 -0
- package/features/thread-list/data/usePullRequestAnswers.ts +36 -0
- package/features/thread-list/data/useSystemFacts.ts +52 -0
- package/features/thread-list/icons.ts +1 -0
- package/features/thread-list/index.ts +0 -1
- package/features/thread-list/model/attention.ts +2 -66
- package/features/thread-list/model/branches.ts +27 -0
- package/features/thread-list/model/counters.ts +5 -5
- package/features/thread-list/model/details.ts +6 -6
- package/features/thread-list/model/expansion.ts +5 -5
- package/features/thread-list/model/groups.ts +2 -2
- package/features/thread-list/model/labels.ts +28 -17
- package/features/thread-list/model/menu.ts +1 -0
- package/features/thread-list/model/move.ts +1 -1
- package/features/thread-list/model/notes.ts +2 -28
- package/features/thread-list/model/settings.ts +1 -17
- package/features/thread-list/model/settled.ts +122 -0
- package/features/thread-list/model/share.ts +1 -7
- package/features/thread-list/model/sort.ts +5 -16
- package/features/thread-list/model/state.ts +19 -6
- package/features/thread-list/model/toggles.ts +35 -24
- package/features/thread-list/model/{families.ts → trees.ts} +40 -28
- package/features/thread-list/model/view.ts +200 -274
- package/features/thread-list/testing/fixtures.ts +41 -12
- package/package.json +1 -1
- package/server/cli.ts +1 -1
- package/server/import.ts +36 -6
- package/server/preference-store.ts +2 -1
- package/server.ts +1 -1
- package/shared/preferences.ts +38 -27
- package/skills/thread-glance/SKILL.md +11 -12
- package/features/thread-list/data/useAttentionHold.ts +0 -19
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Settled threads: which trees go behind their group's settled fold.
|
|
2
|
+
// Worked out afresh on every build, so any activity unsettles a thread. Pure.
|
|
3
|
+
import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
|
|
4
|
+
import type { SettleAfter } from "@/shared/preferences";
|
|
5
|
+
import { isQuietThread } from "./state";
|
|
6
|
+
import type { ThreadInfo, ThreadTree } from "./trees";
|
|
7
|
+
|
|
8
|
+
const HOUR = 60 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
/** How long a thread goes without activity before it settles, per Settle after choice. */
|
|
11
|
+
export const SETTLE_AFTER_MS: Readonly<Record<Exclude<SettleAfter, "never">, number>> = {
|
|
12
|
+
"12h": 12 * HOUR,
|
|
13
|
+
"1d": 24 * HOUR,
|
|
14
|
+
"3d": 3 * 24 * HOUR,
|
|
15
|
+
"1w": 7 * 24 * HOUR,
|
|
16
|
+
};
|
|
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
|
+
export interface SettleInputs {
|
|
52
|
+
now: number;
|
|
53
|
+
settleAfter: SettleAfter;
|
|
54
|
+
/** Server stamps: when each thread last started and finished a turn. */
|
|
55
|
+
startedAt: Readonly<Record<string, number>>;
|
|
56
|
+
finishedAt: Readonly<Record<string, number>>;
|
|
57
|
+
pullRequestOf(thread: PluginSidebarThread): PullRequestFact;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A thread's own last activity: its creation, its last finish or failure
|
|
62
|
+
* (`latestAttentionAt`) and the server's stamps of its last start and
|
|
63
|
+
* finish. `updatedAt` is left out because opening or renaming a thread
|
|
64
|
+
* moves it too.
|
|
65
|
+
*/
|
|
66
|
+
export function lastActivityAt(
|
|
67
|
+
thread: Pick<PluginSidebarThread, "id" | "createdAt" | "latestAttentionAt">,
|
|
68
|
+
stamps: Pick<SettleInputs, "startedAt" | "finishedAt">,
|
|
69
|
+
): number {
|
|
70
|
+
return Math.max(
|
|
71
|
+
thread.createdAt,
|
|
72
|
+
thread.latestAttentionAt,
|
|
73
|
+
stamps.startedAt[thread.id] ?? 0,
|
|
74
|
+
stamps.finishedAt[thread.id] ?? 0,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A settled thread: quiet as if no thread were open, not needing attention,
|
|
80
|
+
* not pinned, with no open pull request, and either its pull request merged
|
|
81
|
+
* or closed or its own last activity is older than the Settle after period.
|
|
82
|
+
* Hidden and archived threads take the same test.
|
|
83
|
+
*/
|
|
84
|
+
export function isSettledThread(info: ThreadInfo, inputs: SettleInputs): boolean {
|
|
85
|
+
const thread = info.thread;
|
|
86
|
+
if (!mightSettle(info)) return false;
|
|
87
|
+
const pullRequest = inputs.pullRequestOf(thread);
|
|
88
|
+
if (pullRequest === "unknown" || pullRequest === "open" || pullRequest === "draft") return false;
|
|
89
|
+
if (pullRequest === "merged" || pullRequest === "closed") return true;
|
|
90
|
+
if (inputs.settleAfter === "never") return false;
|
|
91
|
+
return inputs.now - lastActivityAt(thread, inputs) >= SETTLE_AFTER_MS[inputs.settleAfter];
|
|
92
|
+
}
|
|
93
|
+
|
|
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
|
+
/** A tree settles as one unit: when every thread in it is settled. */
|
|
102
|
+
export function isSettledTree(tree: Pick<ThreadTree, "root" | "descendants">, inputs: SettleInputs): boolean {
|
|
103
|
+
return isSettledThread(tree.root, inputs) && tree.descendants.every((info) => isSettledThread(info, inputs));
|
|
104
|
+
}
|
|
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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Structural sharing between two list views: whatever did not change keeps
|
|
2
2
|
// the previous object, so memoized rows and groups skip their render. Pure.
|
|
3
|
-
import type { GroupView, ListView,
|
|
3
|
+
import type { GroupView, ListView, Row } from "./view";
|
|
4
4
|
|
|
5
5
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
6
6
|
if (value === null || typeof value !== "object") return false;
|
|
@@ -86,11 +86,6 @@ function shareGroups(previousGroups: GroupView[], all: ReadonlyMap<string, Group
|
|
|
86
86
|
return sameItems(previousGroups, result) ? previousGroups : result;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
function shareAttention(previous: AttentionView | null, next: AttentionView | null): AttentionView | null {
|
|
90
|
-
if (previous === null || next === null) return next;
|
|
91
|
-
return shareFields(previous, next, { rows: shareRows(previous.rows, next.rows) });
|
|
92
|
-
}
|
|
93
|
-
|
|
94
89
|
/**
|
|
95
90
|
* `next`, sharing every group and row that did not change since `previous`.
|
|
96
91
|
* Groups match by id and rows by key, never by position. A group keeps its
|
|
@@ -100,7 +95,6 @@ export function shareView(previous: ListView | null, next: ListView): ListView {
|
|
|
100
95
|
if (previous === null) return next;
|
|
101
96
|
const all = new Map([...previous.groups, ...previous.more].map((group) => [group.descriptor.id, group]));
|
|
102
97
|
return shareFields(previous, next, {
|
|
103
|
-
attention: shareAttention(previous.attention, next.attention),
|
|
104
98
|
groups: shareGroups(previous.groups, all, next.groups),
|
|
105
99
|
more: shareGroups(previous.more, all, next.more),
|
|
106
100
|
});
|
|
@@ -5,14 +5,13 @@ import type { SortDirection, SortField } from "@/shared/preferences";
|
|
|
5
5
|
export interface SortOptions {
|
|
6
6
|
field: SortField;
|
|
7
7
|
direction: SortDirection;
|
|
8
|
-
workingFirst: boolean;
|
|
9
8
|
}
|
|
10
9
|
|
|
11
10
|
/** What a comparator needs to know about a root beyond the thread itself. */
|
|
12
11
|
export interface SortKey {
|
|
13
12
|
thread: PluginSidebarThread;
|
|
14
|
-
/** Largest `latestAttentionAt` over the
|
|
15
|
-
|
|
13
|
+
/** Largest `latestAttentionAt` over the tree. */
|
|
14
|
+
treeAttention: number;
|
|
16
15
|
}
|
|
17
16
|
|
|
18
17
|
function byCreated(a: SortKey, b: SortKey): number {
|
|
@@ -28,7 +27,7 @@ function byAlpha(a: SortKey, b: SortKey): number {
|
|
|
28
27
|
}
|
|
29
28
|
|
|
30
29
|
function byUpdated(a: SortKey, b: SortKey): number {
|
|
31
|
-
const diff = b.
|
|
30
|
+
const diff = b.treeAttention - a.treeAttention;
|
|
32
31
|
if (diff !== 0) return diff;
|
|
33
32
|
return byCreated(a, b);
|
|
34
33
|
}
|
|
@@ -48,22 +47,12 @@ export function effectiveDirection(field: SortField, direction: SortDirection):
|
|
|
48
47
|
return direction === "default" ? naturalDirection(field) : direction;
|
|
49
48
|
}
|
|
50
49
|
|
|
51
|
-
/**
|
|
52
|
-
* The comparator for roots in a group. Working first keeps active threads on
|
|
53
|
-
* top whatever the direction.
|
|
54
|
-
*/
|
|
50
|
+
/** The comparator for roots in a group. */
|
|
55
51
|
export function makeComparator(options: SortOptions): (a: SortKey, b: SortKey) => number {
|
|
56
52
|
const field = effectiveSortField(options.field);
|
|
57
53
|
const sign = effectiveDirection(field, options.direction) === naturalDirection(field) ? 1 : -1;
|
|
58
54
|
const base = field === "alpha" ? byAlpha : field === "created" ? byCreated : byUpdated;
|
|
59
|
-
return (a, b) =>
|
|
60
|
-
if (field === "updated" && options.workingFirst) {
|
|
61
|
-
const aActive = a.thread.status === "active";
|
|
62
|
-
const bActive = b.thread.status === "active";
|
|
63
|
-
if (aActive !== bActive) return aActive ? -1 : 1;
|
|
64
|
-
}
|
|
65
|
-
return sign * base(a, b);
|
|
66
|
-
};
|
|
55
|
+
return (a, b) => sign * base(a, b);
|
|
67
56
|
}
|
|
68
57
|
|
|
69
58
|
/** Pinned order: pinSortKey, then pinnedAt desc, createdAt desc, id. */
|
|
@@ -271,26 +271,39 @@ export function hiddenThreadFlags(flags: ReadonlySet<Flag>): Set<Flag> {
|
|
|
271
271
|
return kept;
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
/** Flags a hidden thread adds to its parent's child dot: what asks for you or failed, and offline. */
|
|
275
|
+
const HIDDEN_DOT_FLAGS: ReadonlySet<Flag> = new Set<Flag>(["waits-on-you", "unread-failed", "queue-failed", "offline"]);
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The flags a thread adds to the child dot of the threads above it. A
|
|
279
|
+
* hidden thread adds only waits-on-you, failed, queued message failed and
|
|
280
|
+
* offline; which trees need attention reads `hiddenThreadFlags` instead,
|
|
281
|
+
* so this leaves it unchanged.
|
|
282
|
+
*/
|
|
283
|
+
export function dotFlagsOf(flags: ReadonlySet<Flag>, isHidden: boolean): Set<Flag> {
|
|
284
|
+
return isHidden ? new Set([...flags].filter((flag) => HIDDEN_DOT_FLAGS.has(flag))) : new Set(flags);
|
|
285
|
+
}
|
|
286
|
+
|
|
274
287
|
/** The most urgent flag, or null. */
|
|
275
288
|
export function mostUrgent(flags: ReadonlySet<Flag>): Flag | null {
|
|
276
289
|
return FLAG_ORDER.find((flag) => flags.has(flag)) ?? null;
|
|
277
290
|
}
|
|
278
291
|
|
|
279
|
-
/** The colour a child
|
|
280
|
-
export type
|
|
292
|
+
/** The colour of a child dot, by the most urgent state among the descendants. */
|
|
293
|
+
export type DotTone = "attention" | "destructive" | "working" | "unread";
|
|
281
294
|
|
|
282
|
-
|
|
283
|
-
export function chipTone(flag: Flag | null): ChipTone {
|
|
295
|
+
export function dotTone(flag: Flag): DotTone {
|
|
284
296
|
switch (flag) {
|
|
285
297
|
case "waits-on-you":
|
|
298
|
+
case "offline":
|
|
286
299
|
return "attention";
|
|
287
300
|
case "unread-failed":
|
|
288
301
|
case "queue-failed":
|
|
289
302
|
return "destructive";
|
|
290
303
|
case "working":
|
|
291
304
|
return "working";
|
|
292
|
-
|
|
293
|
-
return "
|
|
305
|
+
case "unread":
|
|
306
|
+
return "unread";
|
|
294
307
|
}
|
|
295
308
|
}
|
|
296
309
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// What a user's click on a chip, fold or header changes.
|
|
2
2
|
// Pure: returns the preference patch and which auto-expand targets to drop.
|
|
3
3
|
import type { Preferences } from "@/shared/preferences";
|
|
4
|
-
import { ancestorsOf, type
|
|
4
|
+
import { ancestorsOf, type ThreadTree, type Forest } from "./trees";
|
|
5
5
|
import { isGroupCollapsed, toggleGroupCollapse } from "./groups";
|
|
6
6
|
import { isDoneUnseen } from "./state";
|
|
7
|
-
import type { GroupView, OlderRow, ThreadRow } from "./view";
|
|
7
|
+
import type { GroupView, OlderRow, SettledRow, ThreadRow } from "./view";
|
|
8
8
|
|
|
9
9
|
export interface ToggleOutcome {
|
|
10
10
|
patch: Partial<Preferences>;
|
|
@@ -29,36 +29,31 @@ export function toggleChip(row: ThreadRow, prefs: Preferences, forest: Forest):
|
|
|
29
29
|
: openChildren(id, prefs);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/** Opens a parent's children, as its chip does
|
|
32
|
+
/** Opens a parent's children, as its chip does, from an auto-reveal's fold. */
|
|
33
33
|
export function openChildren(parentId: string, prefs: Preferences): ToggleOutcome {
|
|
34
34
|
return { patch: { expandedChildren: [...without(prefs.expandedChildren, parentId), parentId] }, drop: null };
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
/** Opens or closes an open tree's "N more child threads" fold. */
|
|
38
|
+
export function toggleOlder(row: OlderRow, prefs: Preferences, forest: Forest): ToggleOutcome {
|
|
38
39
|
if (row.scope === "reveal") return openChildren(row.scopeId, prefs);
|
|
39
40
|
if (!row.expanded) {
|
|
40
41
|
return { patch: { expandedOlder: [...without(prefs.expandedOlder, row.scopeId), row.scopeId] }, drop: null };
|
|
41
42
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
return {
|
|
49
|
-
patch,
|
|
50
|
-
drop: (id) => {
|
|
51
|
-
const family = forest.familyOf.get(id);
|
|
52
|
-
return family !== undefined && quietRoots.has(family.root.thread.id) && inGroup.has(family.root.thread.id);
|
|
53
|
-
},
|
|
54
|
-
};
|
|
43
|
+
return { patch: { expandedOlder: without(prefs.expandedOlder, row.scopeId) }, drop: under(forest, row.scopeId) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Opens or closes a group's settled fold. */
|
|
47
|
+
export function toggleSettled(row: SettledRow, prefs: Preferences): ToggleOutcome {
|
|
48
|
+
const rest = without(prefs.openSettledFolds, row.groupId);
|
|
49
|
+
return { patch: { openSettledFolds: row.expanded ? rest : [...rest, row.groupId] }, drop: null };
|
|
55
50
|
}
|
|
56
51
|
|
|
57
52
|
export function toggleGroup(group: GroupView, prefs: Preferences, forest: Forest): ToggleOutcome {
|
|
58
53
|
const inGroup = new Set(group.rootIds);
|
|
59
54
|
const dropGroup = (id: string) => {
|
|
60
|
-
const
|
|
61
|
-
return
|
|
55
|
+
const tree = forest.treeOf.get(id);
|
|
56
|
+
return tree !== undefined && inGroup.has(tree.root.thread.id);
|
|
62
57
|
};
|
|
63
58
|
if (group.collapsed) {
|
|
64
59
|
return {
|
|
@@ -78,19 +73,35 @@ export interface MarkAllRead {
|
|
|
78
73
|
seen: string[];
|
|
79
74
|
}
|
|
80
75
|
|
|
81
|
-
/** Every unread thread in the
|
|
76
|
+
/** Every unread thread in the trees, descendants included: Mark all read, and a root's Mark read for its tree. */
|
|
82
77
|
export function markAllReadPlan(
|
|
83
|
-
|
|
78
|
+
trees: readonly ThreadTree[],
|
|
84
79
|
context: { activeThreadId: string | null; finishedAt: Readonly<Record<string, number>>; seenAt: Readonly<Record<string, number>> },
|
|
85
80
|
): MarkAllRead {
|
|
86
81
|
const read: string[] = [];
|
|
87
82
|
const seen: string[] = [];
|
|
88
|
-
for (const
|
|
89
|
-
for (const info of [
|
|
90
|
-
if (!info.unread
|
|
83
|
+
for (const tree of trees) {
|
|
84
|
+
for (const info of [tree.root, ...tree.descendants]) {
|
|
85
|
+
if (!info.unread) continue;
|
|
91
86
|
read.push(info.thread.id);
|
|
92
87
|
if (isDoneUnseen(info.thread, context)) seen.push(info.thread.id);
|
|
93
88
|
}
|
|
94
89
|
}
|
|
95
90
|
return { read, seen };
|
|
96
91
|
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* What one row's Mark read marks: on a tree's root, every unread thread in
|
|
95
|
+
* the tree; on any other row, the thread alone.
|
|
96
|
+
*/
|
|
97
|
+
export function markReadPlanFor(
|
|
98
|
+
threadId: string,
|
|
99
|
+
forest: Pick<Forest, "infos" | "treeOf">,
|
|
100
|
+
context: { activeThreadId: string | null; finishedAt: Readonly<Record<string, number>>; seenAt: Readonly<Record<string, number>> },
|
|
101
|
+
): MarkAllRead {
|
|
102
|
+
const tree = forest.treeOf.get(threadId);
|
|
103
|
+
if (tree !== undefined && tree.root.thread.id === threadId) return markAllReadPlan([tree], context);
|
|
104
|
+
const thread = forest.infos.get(threadId)?.thread;
|
|
105
|
+
if (thread === undefined) return { read: [threadId], seen: [] };
|
|
106
|
+
return { read: [threadId], seen: isDoneUnseen(thread, context) ? [threadId] : [] };
|
|
107
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Thread trees: who attaches to whom, and what each tree
|
|
2
2
|
// carries. Pure.
|
|
3
3
|
import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
|
|
4
4
|
import {
|
|
5
5
|
computeState,
|
|
6
|
+
dotFlagsOf,
|
|
6
7
|
hiddenThreadFlags,
|
|
7
8
|
isQuietThread,
|
|
8
9
|
isUnread,
|
|
@@ -14,11 +15,11 @@ import {
|
|
|
14
15
|
} from "./state";
|
|
15
16
|
import { attentionFlagsOf, isOrphanedFailure } from "./attention";
|
|
16
17
|
import { compareCreationAscending } from "./sort";
|
|
17
|
-
import {
|
|
18
|
+
import { needsKindOf, rowNote, type RowNote } from "./notes";
|
|
18
19
|
import type { ThreadNotes } from "@/shared/contract";
|
|
19
20
|
import type { ChildAttention } from "@/shared/preferences";
|
|
20
21
|
|
|
21
|
-
/** States that keep a child out of
|
|
22
|
+
/** States that keep a child out of an open tree's "N more child threads" fold with `childAttention` `blocked`: working, setting up, background work. */
|
|
22
23
|
const RUNNING: ReadonlySet<StateKind> = new Set<StateKind>(["working", "background"]);
|
|
23
24
|
|
|
24
25
|
/** Everything the list knows about one thread. */
|
|
@@ -28,16 +29,19 @@ export interface ThreadInfo {
|
|
|
28
29
|
unread: boolean;
|
|
29
30
|
/** Own flags; for hidden threads only waits-on-you and unread-failed. */
|
|
30
31
|
flags: ReadonlySet<Flag>;
|
|
32
|
+
/** What it adds to the child dot of the threads above it (see `dotFlagsOf`). */
|
|
33
|
+
dotFlags: ReadonlySet<Flag>;
|
|
31
34
|
/**
|
|
32
|
-
* The flags of this thread that make it need attention, for
|
|
33
|
-
* counters and auto-reveal: a child counts
|
|
35
|
+
* The flags of this thread that make it need attention, for collapsed
|
|
36
|
+
* groups, the counters, the need-you filter and auto-reveal: a child counts
|
|
37
|
+
* less than a root.
|
|
34
38
|
*/
|
|
35
39
|
attentionFlags: Set<Flag>;
|
|
36
40
|
/** Quiet test for the thread alone. */
|
|
37
41
|
quiet: boolean;
|
|
38
42
|
/**
|
|
39
43
|
* A quiet thread, as if no thread were open: nothing to see behind a
|
|
40
|
-
*
|
|
44
|
+
* tree's fold. A root, or any child when `childAttention` is
|
|
41
45
|
* `everything`, takes the quiet test without the open thread's exemption.
|
|
42
46
|
* Otherwise a child is quiet unless it runs or needs attention, so finishing
|
|
43
47
|
* unread folds it; an archived child is always quiet. The fold reads this,
|
|
@@ -49,23 +53,25 @@ export interface ThreadInfo {
|
|
|
49
53
|
parentId: string | null;
|
|
50
54
|
/** Why it waits on you or failed, for the line under the row. */
|
|
51
55
|
note: RowNote | null;
|
|
52
|
-
/** Why it needs attention, for the line under its row in Needs attention. */
|
|
53
|
-
attentionNote: RowNote | null;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
/** What a thread's descendants add up to, for its chip and
|
|
58
|
+
/** What a thread's descendants add up to, for its chip, its child dot and folding. */
|
|
57
59
|
export interface Subtree {
|
|
58
60
|
/** Descendants, depth-first in creation order, hidden ones included. */
|
|
59
61
|
descendants: ThreadInfo[];
|
|
60
62
|
/** Union of the descendants' Needs attention flags and working, archived ones left out. */
|
|
61
63
|
flags: ReadonlySet<Flag>;
|
|
62
|
-
/**
|
|
64
|
+
/** Union of the descendants' `dotFlags`, archived ones left out: the child dot. */
|
|
65
|
+
dotFlags: ReadonlySet<Flag>;
|
|
66
|
+
/** Visible descendants at any depth. */
|
|
63
67
|
visibleCount: number;
|
|
68
|
+
/** Visible direct children: the chip's number. */
|
|
69
|
+
childCount: number;
|
|
64
70
|
/** Every visible descendant is quiet, as if no thread were open, so the subtree adds nothing to see when folded. */
|
|
65
71
|
quietIgnoringOpen: boolean;
|
|
66
72
|
}
|
|
67
73
|
|
|
68
|
-
export interface
|
|
74
|
+
export interface ThreadTree {
|
|
69
75
|
root: ThreadInfo;
|
|
70
76
|
/** Descendants, depth-first in creation order, hidden ones included. */
|
|
71
77
|
descendants: ThreadInfo[];
|
|
@@ -73,11 +79,11 @@ export interface Family {
|
|
|
73
79
|
descendantFlags: ReadonlySet<Flag>;
|
|
74
80
|
/** The same over root and descendants (rollup, folder rows). */
|
|
75
81
|
flags: ReadonlySet<Flag>;
|
|
76
|
-
/** Needs attention flags over root and descendants:
|
|
82
|
+
/** Needs attention flags over root and descendants: the tree needs attention when any is set. */
|
|
77
83
|
attentionFlags: ReadonlySet<Flag>;
|
|
78
84
|
/** Visible descendants: the chip's number. */
|
|
79
85
|
visibleDescendantCount: number;
|
|
80
|
-
/** Largest latestAttentionAt over the
|
|
86
|
+
/** Largest latestAttentionAt over the tree. */
|
|
81
87
|
latestAttentionAt: number;
|
|
82
88
|
/** Every thread quiet and the active thread not in it. */
|
|
83
89
|
quiet: boolean;
|
|
@@ -93,8 +99,8 @@ export interface Forest {
|
|
|
93
99
|
infos: ReadonlyMap<string, ThreadInfo>;
|
|
94
100
|
/** Visible child ids per parent id, creation order; hidden children included. */
|
|
95
101
|
children: ReadonlyMap<string, readonly string[]>;
|
|
96
|
-
|
|
97
|
-
|
|
102
|
+
trees: readonly ThreadTree[];
|
|
103
|
+
treeOf: ReadonlyMap<string, ThreadTree>;
|
|
98
104
|
/** The rollup under every thread with a row, by thread id. */
|
|
99
105
|
subtrees: ReadonlyMap<string, Subtree>;
|
|
100
106
|
}
|
|
@@ -161,13 +167,13 @@ export function buildForest(inputs: ForestInputs): Forest {
|
|
|
161
167
|
state,
|
|
162
168
|
unread,
|
|
163
169
|
flags: thread.isHidden ? hiddenThreadFlags(flags) : flags,
|
|
170
|
+
dotFlags: dotFlagsOf(flags, thread.isHidden),
|
|
164
171
|
attentionFlags: new Set<Flag>(),
|
|
165
172
|
quiet: isQuietThread(state, unread, isActive),
|
|
166
173
|
quietIgnoringOpen: false,
|
|
167
174
|
isActive,
|
|
168
175
|
parentId: attachParent(thread, byId),
|
|
169
176
|
note: rowNote(thread, inputs.notes?.[thread.id]),
|
|
170
|
-
attentionNote: null,
|
|
171
177
|
});
|
|
172
178
|
}
|
|
173
179
|
|
|
@@ -181,7 +187,6 @@ export function buildForest(inputs: ForestInputs): Forest {
|
|
|
181
187
|
parent !== undefined &&
|
|
182
188
|
isOrphanedFailure(info.thread, info.flags, { ...parent, finishedAt: inputs.finishedAt[parent.thread.id] }),
|
|
183
189
|
});
|
|
184
|
-
info.attentionNote = attentionNote(info.thread, inputs.notes?.[info.thread.id], info.attentionFlags);
|
|
185
190
|
info.quietIgnoringOpen =
|
|
186
191
|
parent === undefined || mode === "everything"
|
|
187
192
|
? isQuietThread(info.state, info.unread, false)
|
|
@@ -192,7 +197,7 @@ export function buildForest(inputs: ForestInputs): Forest {
|
|
|
192
197
|
const roots: ThreadInfo[] = [];
|
|
193
198
|
for (const info of infos.values()) {
|
|
194
199
|
if (info.parentId === null) {
|
|
195
|
-
// A hidden thread with no visible ancestor has no row and no
|
|
200
|
+
// A hidden thread with no visible ancestor has no row and no tree.
|
|
196
201
|
if (!info.thread.isHidden) roots.push(info);
|
|
197
202
|
continue;
|
|
198
203
|
}
|
|
@@ -211,7 +216,9 @@ export function buildForest(inputs: ForestInputs): Forest {
|
|
|
211
216
|
if (cached !== undefined) return cached;
|
|
212
217
|
const descendants: ThreadInfo[] = [];
|
|
213
218
|
const flags = new Set<Flag>();
|
|
219
|
+
const dotFlags = new Set<Flag>();
|
|
214
220
|
let visibleCount = 0;
|
|
221
|
+
let childCount = 0;
|
|
215
222
|
let quietIgnoringOpen = true;
|
|
216
223
|
path.add(id);
|
|
217
224
|
for (const childId of children.get(id) ?? []) {
|
|
@@ -222,22 +229,27 @@ export function buildForest(inputs: ForestInputs): Forest {
|
|
|
222
229
|
if (!child.thread.isArchived) {
|
|
223
230
|
for (const flag of child.attentionFlags) flags.add(flag);
|
|
224
231
|
if (child.flags.has("working")) flags.add("working");
|
|
232
|
+
for (const flag of child.dotFlags) dotFlags.add(flag);
|
|
225
233
|
}
|
|
226
234
|
for (const flag of below.flags) flags.add(flag);
|
|
235
|
+
for (const flag of below.dotFlags) dotFlags.add(flag);
|
|
227
236
|
if (child.thread.isHidden ? child.attentionFlags.size > 0 : !child.quietIgnoringOpen) quietIgnoringOpen = false;
|
|
228
|
-
if (!child.thread.isHidden)
|
|
237
|
+
if (!child.thread.isHidden) {
|
|
238
|
+
visibleCount += 1;
|
|
239
|
+
childCount += 1;
|
|
240
|
+
}
|
|
229
241
|
visibleCount += below.visibleCount;
|
|
230
242
|
if (!below.quietIgnoringOpen) quietIgnoringOpen = false;
|
|
231
243
|
}
|
|
232
244
|
path.delete(id);
|
|
233
|
-
const subtree: Subtree = { descendants, flags, visibleCount, quietIgnoringOpen };
|
|
245
|
+
const subtree: Subtree = { descendants, flags, dotFlags, visibleCount, childCount, quietIgnoringOpen };
|
|
234
246
|
subtrees.set(id, subtree);
|
|
235
247
|
return subtree;
|
|
236
248
|
};
|
|
237
249
|
for (const id of infos.keys()) subtreeOf(id, new Set());
|
|
238
250
|
|
|
239
|
-
const
|
|
240
|
-
const
|
|
251
|
+
const trees: ThreadTree[] = [];
|
|
252
|
+
const treeOf = new Map<string, ThreadTree>();
|
|
241
253
|
for (const root of roots) {
|
|
242
254
|
const { descendants, flags: descendantFlags, visibleCount: visibleDescendantCount } = subtrees.get(root.thread.id)!;
|
|
243
255
|
let latestAttentionAt = root.thread.latestAttentionAt;
|
|
@@ -254,7 +266,7 @@ export function buildForest(inputs: ForestInputs): Forest {
|
|
|
254
266
|
} else {
|
|
255
267
|
if (!info.quiet) quiet = false;
|
|
256
268
|
// A child reads as its own fold does, so with `blocked` an unread child
|
|
257
|
-
// does not keep an old
|
|
269
|
+
// does not keep an old tree out of the group's fold.
|
|
258
270
|
if (!info.quietIgnoringOpen) quietIgnoringOpen = false;
|
|
259
271
|
}
|
|
260
272
|
}
|
|
@@ -266,7 +278,7 @@ export function buildForest(inputs: ForestInputs): Forest {
|
|
|
266
278
|
}
|
|
267
279
|
if (root.flags.has("working")) flags.add("working");
|
|
268
280
|
}
|
|
269
|
-
const
|
|
281
|
+
const tree: ThreadTree = {
|
|
270
282
|
root,
|
|
271
283
|
descendants,
|
|
272
284
|
descendantFlags,
|
|
@@ -278,11 +290,11 @@ export function buildForest(inputs: ForestInputs): Forest {
|
|
|
278
290
|
quietIgnoringOpen,
|
|
279
291
|
containsActive,
|
|
280
292
|
};
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
for (const info of descendants)
|
|
293
|
+
trees.push(tree);
|
|
294
|
+
treeOf.set(root.thread.id, tree);
|
|
295
|
+
for (const info of descendants) treeOf.set(info.thread.id, tree);
|
|
284
296
|
}
|
|
285
|
-
return { infos, children,
|
|
297
|
+
return { infos, children, trees, treeOf, subtrees };
|
|
286
298
|
}
|
|
287
299
|
|
|
288
300
|
/** Ids from `id` up to (not including) `stopAt`, nearest first. */
|