@gperezmz/bb-plugin-thread-glance 0.2.1 → 0.2.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/README.md +2 -2
- package/dist/app.css +1 -1
- package/dist/app.js +29 -29
- package/dist/app.meta.json +1 -1
- package/dist/server.js +1 -1
- package/dist/server.js.map +2 -2
- package/dist/server.meta.json +1 -1
- package/features/thread-list/components/FoldRows.tsx +12 -11
- package/features/thread-list/components/GroupSection.tsx +131 -102
- package/features/thread-list/components/ThreadDetails.tsx +2 -3
- package/features/thread-list/components/ThreadList.tsx +10 -9
- package/features/thread-list/components/ThreadRowView.tsx +22 -18
- package/features/thread-list/components/Toolbar.tsx +1 -1
- package/features/thread-list/components/controller.ts +2 -0
- package/features/thread-list/components/glyphs.tsx +19 -1
- package/features/thread-list/data/{useNeedsYouHold.ts → useAttentionHold.ts} +5 -5
- package/features/thread-list/model/{needs-you.ts → attention.ts} +24 -24
- package/features/thread-list/model/counters.ts +3 -3
- package/features/thread-list/model/details.ts +1 -1
- package/features/thread-list/model/expansion.ts +10 -10
- package/features/thread-list/model/families.ts +26 -22
- package/features/thread-list/model/layout.ts +0 -43
- package/features/thread-list/model/notes.ts +37 -4
- package/features/thread-list/model/settings.ts +1 -1
- package/features/thread-list/model/share.ts +3 -3
- package/features/thread-list/model/state.ts +1 -1
- package/features/thread-list/model/toggles.ts +7 -5
- package/features/thread-list/model/view.ts +68 -47
- package/features/thread-list/testing/fixtures.ts +3 -3
- package/package.json +1 -1
- package/server/cli.ts +1 -1
- package/shared/preferences.ts +2 -2
- package/skills/thread-glance/SKILL.md +4 -4
- package/features/thread-list/components/Rails.tsx +0 -27
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// What needs
|
|
1
|
+
// What needs attention, thread by thread. The Needs attention section, the header
|
|
2
2
|
// counters, the section's order and auto-reveal all read the set this
|
|
3
3
|
// builds. Pure.
|
|
4
4
|
import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
|
|
@@ -7,7 +7,7 @@ import type { Family } from "./families";
|
|
|
7
7
|
import { type Flag, type StateKind, type ThreadState } from "./state";
|
|
8
8
|
|
|
9
9
|
/** The flags that count for a root thread. Working is not one. */
|
|
10
|
-
export const
|
|
10
|
+
export const ROOT_ATTENTION: ReadonlySet<Flag> = new Set<Flag>([
|
|
11
11
|
"waits-on-you",
|
|
12
12
|
"unread-failed",
|
|
13
13
|
"queue-failed",
|
|
@@ -60,18 +60,18 @@ export function isOrphanedFailure(
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
* The flags of one thread that make it need
|
|
63
|
+
* The flags of one thread that make it need attention. A root keeps its own. A
|
|
64
64
|
* child counts when it waits on you or is offline, or for an orphaned
|
|
65
65
|
* failure; a finished-unread child never does. With `everything`, a child
|
|
66
66
|
* counts like a root.
|
|
67
67
|
*/
|
|
68
|
-
export function
|
|
68
|
+
export function attentionFlagsOf(
|
|
69
69
|
flags: ReadonlySet<Flag>,
|
|
70
70
|
options: { isRoot: boolean; mode: ChildAttention; orphaned: boolean },
|
|
71
71
|
): Set<Flag> {
|
|
72
72
|
const kept = new Set<Flag>();
|
|
73
73
|
if (options.isRoot || options.mode === "everything") {
|
|
74
|
-
for (const flag of flags) if (
|
|
74
|
+
for (const flag of flags) if (ROOT_ATTENTION.has(flag)) kept.add(flag);
|
|
75
75
|
return kept;
|
|
76
76
|
}
|
|
77
77
|
if (flags.has("waits-on-you")) kept.add("waits-on-you");
|
|
@@ -83,51 +83,51 @@ export function needsYouFlagsOf(
|
|
|
83
83
|
return kept;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
/** Whether a set of Needs
|
|
87
|
-
export function revealsOn(
|
|
88
|
-
if (
|
|
89
|
-
return !isRoot &&
|
|
86
|
+
/** Whether a set of Needs attention flags is worth an auto-reveal: it waits on you or failed. */
|
|
87
|
+
export function revealsOn(attention: ReadonlySet<Flag>, isRoot: boolean): boolean {
|
|
88
|
+
if (attention.has("waits-on-you") || attention.has("unread-failed")) return true;
|
|
89
|
+
return !isRoot && attention.has("queue-failed");
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/** What the section reads of a family. */
|
|
93
|
-
type SectionFamily = Pick<Family, "root" | "
|
|
93
|
+
type SectionFamily = Pick<Family, "root" | "attentionFlags">;
|
|
94
94
|
|
|
95
|
-
/** What Needs
|
|
96
|
-
export interface
|
|
95
|
+
/** What Needs attention remembers between renders: the family it holds, and the family that was open. */
|
|
96
|
+
export interface AttentionHold {
|
|
97
97
|
/** The root of the family held in the section, if any. */
|
|
98
98
|
heldRootId: string | null;
|
|
99
99
|
/** The root of the family the open thread belonged to at the last render, if any. */
|
|
100
100
|
openRootId: string | null;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
export const NO_HOLD:
|
|
103
|
+
export const NO_HOLD: AttentionHold = { heldRootId: null, openRootId: null };
|
|
104
104
|
|
|
105
105
|
/**
|
|
106
|
-
* Whether a family is in the Needs
|
|
107
|
-
* of its threads needs
|
|
106
|
+
* Whether a family is in the Needs attention section: it is the held family, or one
|
|
107
|
+
* of its threads needs attention and none of them is open. Nothing moves while you
|
|
108
108
|
* are inside a family, so the open family only enters by being judged when
|
|
109
|
-
* opened (see `
|
|
109
|
+
* opened (see `holdAttention`).
|
|
110
110
|
*/
|
|
111
|
-
export function
|
|
111
|
+
export function inAttention(family: SectionFamily, heldRootId: string | null, openRootId: string | null): boolean {
|
|
112
112
|
const rootId = family.root.thread.id;
|
|
113
113
|
if (rootId === heldRootId) return true;
|
|
114
|
-
return family.
|
|
114
|
+
return family.attentionFlags.size > 0 && rootId !== openRootId;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
/**
|
|
118
118
|
* The hold after a render. A family is judged when a thread in it is opened
|
|
119
|
-
* from outside it: it is held if something in it needs
|
|
120
|
-
* held while one of its threads is open, after nothing in it needs
|
|
121
|
-
* more, until a thread outside it is opened. A family that does not need
|
|
119
|
+
* from outside it: it is held if something in it needs attention then, and it stays
|
|
120
|
+
* held while one of its threads is open, after nothing in it needs attention any
|
|
121
|
+
* more, until a thread outside it is opened. A family that does not need attention
|
|
122
122
|
* when opened is not pulled in later, whatever happens in it; nor is one
|
|
123
|
-
* that never needed
|
|
123
|
+
* that never needed attention. Opening another thread of the open family judges
|
|
124
124
|
* nothing again.
|
|
125
125
|
*/
|
|
126
|
-
export function
|
|
126
|
+
export function holdAttention(previous: AttentionHold, openFamily: SectionFamily | undefined): AttentionHold {
|
|
127
127
|
if (openFamily === undefined) return NO_HOLD;
|
|
128
128
|
const openRootId = openFamily.root.thread.id;
|
|
129
129
|
if (openRootId === previous.openRootId) {
|
|
130
130
|
return { heldRootId: previous.heldRootId === openRootId ? openRootId : null, openRootId };
|
|
131
131
|
}
|
|
132
|
-
return { heldRootId: openFamily.
|
|
132
|
+
return { heldRootId: openFamily.attentionFlags.size > 0 ? openRootId : null, openRootId };
|
|
133
133
|
}
|
|
@@ -13,9 +13,9 @@ export interface Counters {
|
|
|
13
13
|
export const EMPTY_COUNTERS: Counters = { waitsOnYou: 0, failed: 0, offline: 0, working: 0, unread: 0 };
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* Counts the threads in the families. A thread counts what its Needs
|
|
16
|
+
* Counts the threads in the families. A thread counts what its Needs attention
|
|
17
17
|
* flags say, so a child adds to waits-on-you, failed, offline and unread only
|
|
18
|
-
* as "Needs
|
|
18
|
+
* as "Needs attention counts every child" lets it; working counts every thread that
|
|
19
19
|
* runs. Hidden threads carry only waits-on-you and unread-failed flags
|
|
20
20
|
* already; archived threads never count.
|
|
21
21
|
*/
|
|
@@ -24,7 +24,7 @@ export function countFamilies(families: readonly Family[]): Counters {
|
|
|
24
24
|
for (const family of families) {
|
|
25
25
|
for (const info of [family.root, ...family.descendants]) {
|
|
26
26
|
if (info.thread.isArchived) continue;
|
|
27
|
-
const flags = info.
|
|
27
|
+
const flags = info.attentionFlags;
|
|
28
28
|
if (flags.has("waits-on-you")) counters.waitsOnYou += 1;
|
|
29
29
|
if (flags.has("unread-failed") || flags.has("queue-failed")) counters.failed += 1;
|
|
30
30
|
if (flags.has("offline")) counters.offline += 1;
|
|
@@ -16,7 +16,7 @@ const SUMMARY_ORDER: readonly [StateKind, string][] = [
|
|
|
16
16
|
["idle", "idle"],
|
|
17
17
|
];
|
|
18
18
|
|
|
19
|
-
/** "2 working, 1
|
|
19
|
+
/** "2 working, 1 needing you, 3 idle": visible descendants by state. */
|
|
20
20
|
export function childSummary(descendants: readonly ThreadInfo[]): string | null {
|
|
21
21
|
const counts = new Map<StateKind, number>();
|
|
22
22
|
for (const info of descendants) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Auto-expansion: transitions found by diffing snapshots, and the
|
|
2
2
|
// transient targets they leave behind. Pure.
|
|
3
|
-
import { revealsOn } from "./
|
|
3
|
+
import { revealsOn } from "./attention";
|
|
4
4
|
import type { Forest } from "./families";
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -15,20 +15,20 @@ export type Targets = ReadonlyMap<string, TargetKind>;
|
|
|
15
15
|
export interface Snapshot {
|
|
16
16
|
activeThreadId: string | null;
|
|
17
17
|
unread: ReadonlySet<string>;
|
|
18
|
-
/** Threads that wait on you or failed, as Needs
|
|
19
|
-
|
|
18
|
+
/** Threads that wait on you or failed, as Needs attention counts them. */
|
|
19
|
+
attentionIds: ReadonlySet<string>;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export function snapshotOf(forest: Forest, activeThreadId: string | null): Snapshot {
|
|
23
23
|
const unread = new Set<string>();
|
|
24
|
-
const
|
|
24
|
+
const attentionIds = new Set<string>();
|
|
25
25
|
for (const info of forest.infos.values()) {
|
|
26
26
|
if (info.thread.isArchived) continue;
|
|
27
|
-
// A finished child that does not need
|
|
28
|
-
if (info.
|
|
29
|
-
if (revealsOn(info.
|
|
27
|
+
// A finished child that does not need attention never opens anything.
|
|
28
|
+
if (info.attentionFlags.has("unread") && !info.thread.isHidden) unread.add(info.thread.id);
|
|
29
|
+
if (revealsOn(info.attentionFlags, info.parentId === null)) attentionIds.add(info.thread.id);
|
|
30
30
|
}
|
|
31
|
-
return { activeThreadId, unread,
|
|
31
|
+
return { activeThreadId, unread, attentionIds };
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
|
@@ -40,8 +40,8 @@ export function detectTransitions(previous: Snapshot | null, next: Snapshot): Ma
|
|
|
40
40
|
if (next.activeThreadId !== null && next.activeThreadId !== previous?.activeThreadId) {
|
|
41
41
|
found.set(next.activeThreadId, "reveal");
|
|
42
42
|
}
|
|
43
|
-
for (const id of next.
|
|
44
|
-
if (!previous?.
|
|
43
|
+
for (const id of next.attentionIds) {
|
|
44
|
+
if (!previous?.attentionIds.has(id)) found.set(id, "reveal");
|
|
45
45
|
}
|
|
46
46
|
for (const id of next.unread) {
|
|
47
47
|
if (id === next.activeThreadId || found.has(id)) continue;
|
|
@@ -12,9 +12,9 @@ import {
|
|
|
12
12
|
type StateKind,
|
|
13
13
|
type ThreadState,
|
|
14
14
|
} from "./state";
|
|
15
|
-
import {
|
|
15
|
+
import { attentionFlagsOf, isOrphanedFailure } from "./attention";
|
|
16
16
|
import { compareCreationAscending } from "./sort";
|
|
17
|
-
import { needsKindOf, rowNote, type RowNote } from "./notes";
|
|
17
|
+
import { attentionNote, needsKindOf, rowNote, type RowNote } from "./notes";
|
|
18
18
|
import type { ThreadNotes } from "@/shared/contract";
|
|
19
19
|
import type { ChildAttention } from "@/shared/preferences";
|
|
20
20
|
|
|
@@ -29,17 +29,17 @@ export interface ThreadInfo {
|
|
|
29
29
|
/** Own flags; for hidden threads only waits-on-you and unread-failed. */
|
|
30
30
|
flags: ReadonlySet<Flag>;
|
|
31
31
|
/**
|
|
32
|
-
* The flags of this thread that make it need
|
|
32
|
+
* The flags of this thread that make it need attention, for Needs attention, the
|
|
33
33
|
* counters and auto-reveal: a child counts less than a root.
|
|
34
34
|
*/
|
|
35
|
-
|
|
35
|
+
attentionFlags: Set<Flag>;
|
|
36
36
|
/** Quiet test for the thread alone. */
|
|
37
37
|
quiet: boolean;
|
|
38
38
|
/**
|
|
39
39
|
* A quiet thread, as if no thread were open: nothing to see behind a
|
|
40
40
|
* family's fold. A root, or any child when `childAttention` is
|
|
41
41
|
* `everything`, takes the quiet test without the open thread's exemption.
|
|
42
|
-
* Otherwise a child is quiet unless it runs or needs
|
|
42
|
+
* Otherwise a child is quiet unless it runs or needs attention, so finishing
|
|
43
43
|
* unread folds it; an archived child is always quiet. The fold reads this,
|
|
44
44
|
* so opening a thread never changes which children stay shown.
|
|
45
45
|
*/
|
|
@@ -47,15 +47,17 @@ export interface ThreadInfo {
|
|
|
47
47
|
isActive: boolean;
|
|
48
48
|
/** The visible ancestor it attaches to, or null for a root. */
|
|
49
49
|
parentId: string | null;
|
|
50
|
-
/** Why it
|
|
50
|
+
/** Why it waits on you or failed, for the line under the row. */
|
|
51
51
|
note: RowNote | null;
|
|
52
|
+
/** Why it needs attention, for the line under its row in Needs attention. */
|
|
53
|
+
attentionNote: RowNote | null;
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
/** What a thread's descendants add up to, for its chip and for folding. */
|
|
55
57
|
export interface Subtree {
|
|
56
58
|
/** Descendants, depth-first in creation order, hidden ones included. */
|
|
57
59
|
descendants: ThreadInfo[];
|
|
58
|
-
/** Union of the descendants' Needs
|
|
60
|
+
/** Union of the descendants' Needs attention flags and working, archived ones left out. */
|
|
59
61
|
flags: ReadonlySet<Flag>;
|
|
60
62
|
/** Visible descendants: the chip's number. */
|
|
61
63
|
visibleCount: number;
|
|
@@ -67,12 +69,12 @@ export interface Family {
|
|
|
67
69
|
root: ThreadInfo;
|
|
68
70
|
/** Descendants, depth-first in creation order, hidden ones included. */
|
|
69
71
|
descendants: ThreadInfo[];
|
|
70
|
-
/** Union of the descendants' Needs
|
|
72
|
+
/** Union of the descendants' Needs attention flags and working (the chip rollup). */
|
|
71
73
|
descendantFlags: ReadonlySet<Flag>;
|
|
72
74
|
/** The same over root and descendants (rollup, folder rows). */
|
|
73
75
|
flags: ReadonlySet<Flag>;
|
|
74
|
-
/** Needs
|
|
75
|
-
|
|
76
|
+
/** Needs attention flags over root and descendants: what the section and its order read. */
|
|
77
|
+
attentionFlags: ReadonlySet<Flag>;
|
|
76
78
|
/** Visible descendants: the chip's number. */
|
|
77
79
|
visibleDescendantCount: number;
|
|
78
80
|
/** Largest latestAttentionAt over the family. */
|
|
@@ -104,7 +106,7 @@ export interface ForestInputs extends ThreadContext {
|
|
|
104
106
|
now: number;
|
|
105
107
|
/** Server notes per thread id; absent until loaded. */
|
|
106
108
|
notes?: Readonly<Record<string, ThreadNotes>>;
|
|
107
|
-
/** Which children can need
|
|
109
|
+
/** Which children can need attention; `blocked` when absent. */
|
|
108
110
|
childAttention?: ChildAttention;
|
|
109
111
|
}
|
|
110
112
|
|
|
@@ -159,29 +161,31 @@ export function buildForest(inputs: ForestInputs): Forest {
|
|
|
159
161
|
state,
|
|
160
162
|
unread,
|
|
161
163
|
flags: thread.isHidden ? hiddenThreadFlags(flags) : flags,
|
|
162
|
-
|
|
164
|
+
attentionFlags: new Set<Flag>(),
|
|
163
165
|
quiet: isQuietThread(state, unread, isActive),
|
|
164
166
|
quietIgnoringOpen: false,
|
|
165
167
|
isActive,
|
|
166
168
|
parentId: attachParent(thread, byId),
|
|
167
169
|
note: rowNote(thread, inputs.notes?.[thread.id]),
|
|
170
|
+
attentionNote: null,
|
|
168
171
|
});
|
|
169
172
|
}
|
|
170
173
|
|
|
171
174
|
const mode = inputs.childAttention ?? "blocked";
|
|
172
175
|
for (const info of infos.values()) {
|
|
173
176
|
const parent = info.parentId === null ? undefined : infos.get(info.parentId);
|
|
174
|
-
info.
|
|
177
|
+
info.attentionFlags = attentionFlagsOf(info.flags, {
|
|
175
178
|
isRoot: parent === undefined,
|
|
176
179
|
mode,
|
|
177
180
|
orphaned:
|
|
178
181
|
parent !== undefined &&
|
|
179
182
|
isOrphanedFailure(info.thread, info.flags, { ...parent, finishedAt: inputs.finishedAt[parent.thread.id] }),
|
|
180
183
|
});
|
|
184
|
+
info.attentionNote = attentionNote(info.thread, inputs.notes?.[info.thread.id], info.attentionFlags);
|
|
181
185
|
info.quietIgnoringOpen =
|
|
182
186
|
parent === undefined || mode === "everything"
|
|
183
187
|
? isQuietThread(info.state, info.unread, false)
|
|
184
|
-
: info.thread.isArchived || (!RUNNING.has(info.state.kind) && info.
|
|
188
|
+
: info.thread.isArchived || (!RUNNING.has(info.state.kind) && info.attentionFlags.size === 0);
|
|
185
189
|
}
|
|
186
190
|
|
|
187
191
|
const children = new Map<string, string[]>();
|
|
@@ -216,11 +220,11 @@ export function buildForest(inputs: ForestInputs): Forest {
|
|
|
216
220
|
const below = subtreeOf(childId, path);
|
|
217
221
|
descendants.push(child, ...below.descendants);
|
|
218
222
|
if (!child.thread.isArchived) {
|
|
219
|
-
for (const flag of child.
|
|
223
|
+
for (const flag of child.attentionFlags) flags.add(flag);
|
|
220
224
|
if (child.flags.has("working")) flags.add("working");
|
|
221
225
|
}
|
|
222
226
|
for (const flag of below.flags) flags.add(flag);
|
|
223
|
-
if (child.thread.isHidden ? child.
|
|
227
|
+
if (child.thread.isHidden ? child.attentionFlags.size > 0 : !child.quietIgnoringOpen) quietIgnoringOpen = false;
|
|
224
228
|
if (!child.thread.isHidden) visibleCount += 1;
|
|
225
229
|
visibleCount += below.visibleCount;
|
|
226
230
|
if (!below.quietIgnoringOpen) quietIgnoringOpen = false;
|
|
@@ -240,13 +244,13 @@ export function buildForest(inputs: ForestInputs): Forest {
|
|
|
240
244
|
let quiet = root.quiet;
|
|
241
245
|
let quietIgnoringOpen = isQuietThread(root.state, root.unread, false);
|
|
242
246
|
let containsActive = root.isActive;
|
|
243
|
-
const
|
|
247
|
+
const attentionFlags = new Set<Flag>();
|
|
244
248
|
for (const info of descendants) {
|
|
245
|
-
if (!info.thread.isArchived) for (const flag of info.
|
|
249
|
+
if (!info.thread.isArchived) for (const flag of info.attentionFlags) attentionFlags.add(flag);
|
|
246
250
|
latestAttentionAt = Math.max(latestAttentionAt, info.thread.latestAttentionAt);
|
|
247
251
|
if (info.isActive) containsActive = true;
|
|
248
252
|
if (info.thread.isHidden) {
|
|
249
|
-
if (info.
|
|
253
|
+
if (info.attentionFlags.size > 0) quiet = quietIgnoringOpen = false;
|
|
250
254
|
} else {
|
|
251
255
|
if (!info.quiet) quiet = false;
|
|
252
256
|
// A child reads as its own fold does, so with `blocked` an unread child
|
|
@@ -256,9 +260,9 @@ export function buildForest(inputs: ForestInputs): Forest {
|
|
|
256
260
|
}
|
|
257
261
|
const flags = new Set<Flag>(descendantFlags);
|
|
258
262
|
if (!root.thread.isArchived) {
|
|
259
|
-
for (const flag of root.
|
|
263
|
+
for (const flag of root.attentionFlags) {
|
|
260
264
|
flags.add(flag);
|
|
261
|
-
|
|
265
|
+
attentionFlags.add(flag);
|
|
262
266
|
}
|
|
263
267
|
if (root.flags.has("working")) flags.add("working");
|
|
264
268
|
}
|
|
@@ -267,7 +271,7 @@ export function buildForest(inputs: ForestInputs): Forest {
|
|
|
267
271
|
descendants,
|
|
268
272
|
descendantFlags,
|
|
269
273
|
flags,
|
|
270
|
-
|
|
274
|
+
attentionFlags,
|
|
271
275
|
visibleDescendantCount,
|
|
272
276
|
latestAttentionAt,
|
|
273
277
|
quiet: quiet && !containsActive,
|
|
@@ -11,46 +11,3 @@ export const FOLDED_STEP = 12;
|
|
|
11
11
|
export function rowIndent(depth: number): number {
|
|
12
12
|
return ROOT_INDENT + depth * FOLDED_STEP;
|
|
13
13
|
}
|
|
14
|
-
|
|
15
|
-
/** How far the rail sits from a row's left edge: the centre of the status slot at that level. */
|
|
16
|
-
export function railLeft(level: number): number {
|
|
17
|
-
return rowIndent(level) + 8;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* One level of guide rail on one row. `start` begins under the row's
|
|
22
|
-
* status slot, `full` runs the row's height, `end` stops at its middle.
|
|
23
|
-
*/
|
|
24
|
-
export type Rail = "start" | "full" | "end";
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* The rails every row draws, from the depths of the rows in order. Level `k`
|
|
28
|
-
* runs from the row at depth `k` through the rows below it, and stops at the
|
|
29
|
-
* last of them, so a family's rail ends on its last row, the fold row
|
|
30
|
-
* included. Entry `k` of a row's result is its rail at level `k`, or null.
|
|
31
|
-
*/
|
|
32
|
-
export function railsFor(depths: readonly number[]): (Rail | null)[][] {
|
|
33
|
-
return depths.map((depth, index) => {
|
|
34
|
-
const next = depths[index + 1];
|
|
35
|
-
const rails: (Rail | null)[] = [];
|
|
36
|
-
for (let level = 0; level <= depth; level += 1) {
|
|
37
|
-
const continues = next !== undefined && next > level;
|
|
38
|
-
if (level === depth) rails.push(continues ? "start" : null);
|
|
39
|
-
else rails.push(continues ? "full" : "end");
|
|
40
|
-
}
|
|
41
|
-
return rails;
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* How a row's title reads. Children step back from roots: a size smaller and
|
|
47
|
-
* muted, unless the thread is unread or open. Hover restores the colour in
|
|
48
|
-
* the component.
|
|
49
|
-
*/
|
|
50
|
-
export function titleTreatment(
|
|
51
|
-
depth: number,
|
|
52
|
-
state: { unread: boolean; active: boolean },
|
|
53
|
-
): { small: boolean; muted: boolean } {
|
|
54
|
-
const child = depth > 0;
|
|
55
|
-
return { small: child, muted: child && !state.unread && !state.active };
|
|
56
|
-
}
|
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
// the server stores what it saw; this decides what a row says.
|
|
3
3
|
import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
|
|
4
4
|
import type { Note, ThreadNotes } from "@/shared/contract";
|
|
5
|
-
import { normalizeQueued, normalizeStatus, type NeedsKind } from "./state";
|
|
5
|
+
import { normalizeQueued, normalizeStatus, type Flag, type NeedsKind } from "./state";
|
|
6
6
|
|
|
7
7
|
export interface RowNote {
|
|
8
|
-
/** "Asks", "Approve", "Plan", "Failed"… */
|
|
8
|
+
/** "Asks", "Approve", "Plan", "Failed", "Offline", "Finished"… */
|
|
9
9
|
prefix: string;
|
|
10
|
+
/** Empty when there is nothing to add: the prefix stands alone. */
|
|
10
11
|
text: string;
|
|
11
|
-
tone
|
|
12
|
+
/** The tone of the row's glyph for the same reason. */
|
|
13
|
+
tone: "attention" | "destructive" | "muted";
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
const PENDING_PREFIX: Record<NeedsKind, string> = {
|
|
@@ -25,7 +27,7 @@ export function needsKindOf(notes: ThreadNotes | undefined): NeedsKind | null {
|
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
/**
|
|
28
|
-
* The line under a row: why it needs
|
|
30
|
+
* The line under a row: why it needs attention, or why it failed. Other states
|
|
29
31
|
* have none, so the list stays one line per thread where nothing is wrong.
|
|
30
32
|
*/
|
|
31
33
|
export function rowNote(thread: PluginSidebarThread, notes: ThreadNotes | undefined): RowNote | null {
|
|
@@ -46,6 +48,37 @@ export function rowNote(thread: PluginSidebarThread, notes: ThreadNotes | undefi
|
|
|
46
48
|
return null;
|
|
47
49
|
}
|
|
48
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The line under a row in Needs attention that itself needs attention: its
|
|
53
|
+
* most urgent reason among `attention`, its Needs attention flags. Waits on
|
|
54
|
+
* you, then failed, then offline, then finished; each keeps its prefix when
|
|
55
|
+
* there is no text to follow it.
|
|
56
|
+
*/
|
|
57
|
+
export function attentionNote(
|
|
58
|
+
thread: PluginSidebarThread,
|
|
59
|
+
notes: ThreadNotes | undefined,
|
|
60
|
+
attention: ReadonlySet<Flag>,
|
|
61
|
+
): RowNote | null {
|
|
62
|
+
if (attention.has("waits-on-you")) {
|
|
63
|
+
return { prefix: PENDING_PREFIX[needsKindOf(notes) ?? "input"], text: notes?.pending?.text.trim() ?? "", tone: "attention" };
|
|
64
|
+
}
|
|
65
|
+
if (attention.has("unread-failed")) {
|
|
66
|
+
const text = notes?.failed?.text.trim() ?? "";
|
|
67
|
+
return { prefix: "Failed", text: text.toLowerCase() === "failed" ? "" : text, tone: "destructive" };
|
|
68
|
+
}
|
|
69
|
+
if (attention.has("queue-failed")) {
|
|
70
|
+
return { prefix: "Failed", text: "queued message wasn't sent", tone: "destructive" };
|
|
71
|
+
}
|
|
72
|
+
if (attention.has("offline")) return { prefix: "Offline", text: thread.host?.name.trim() ?? "", tone: "attention" };
|
|
73
|
+
if (attention.has("unread")) return { prefix: "Finished", text: lastReply(notes)?.text.trim() ?? "", tone: "muted" };
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A note as one line of text: "Failed: timeout", or "Failed" alone. */
|
|
78
|
+
export function noteText(note: RowNote): string {
|
|
79
|
+
return note.text === "" ? note.prefix : `${note.prefix}: ${note.text}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
49
82
|
/** The hover card's last-reply line. */
|
|
50
83
|
export function lastReply(notes: ThreadNotes | undefined): Note | null {
|
|
51
84
|
return notes?.done ?? null;
|
|
@@ -18,7 +18,7 @@ export function lifecyclesFor(shown: ThreadsShown): Lifecycle[] {
|
|
|
18
18
|
return [shown];
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/** "Needs
|
|
21
|
+
/** "Needs attention counts every child" is on when children count as `everything`. */
|
|
22
22
|
export function countsEveryChild(childAttention: ChildAttention): boolean {
|
|
23
23
|
return childAttention === "everything";
|
|
24
24
|
}
|
|
@@ -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, AttentionView, 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,7 +86,7 @@ function shareGroups(previousGroups: GroupView[], all: ReadonlyMap<string, Group
|
|
|
86
86
|
return sameItems(previousGroups, result) ? previousGroups : result;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
function
|
|
89
|
+
function shareAttention(previous: AttentionView | null, next: AttentionView | null): AttentionView | null {
|
|
90
90
|
if (previous === null || next === null) return next;
|
|
91
91
|
return shareFields(previous, next, { rows: shareRows(previous.rows, next.rows) });
|
|
92
92
|
}
|
|
@@ -100,7 +100,7 @@ export function shareView(previous: ListView | null, next: ListView): ListView {
|
|
|
100
100
|
if (previous === null) return next;
|
|
101
101
|
const all = new Map([...previous.groups, ...previous.more].map((group) => [group.descriptor.id, group]));
|
|
102
102
|
return shareFields(previous, next, {
|
|
103
|
-
|
|
103
|
+
attention: shareAttention(previous.attention, next.attention),
|
|
104
104
|
groups: shareGroups(previous.groups, all, next.groups),
|
|
105
105
|
more: shareGroups(previous.more, all, next.more),
|
|
106
106
|
});
|
|
@@ -38,7 +38,7 @@ export const FLAG_ORDER: readonly Flag[] = [
|
|
|
38
38
|
*/
|
|
39
39
|
export type Tone = "attention" | "destructive" | "working" | "background" | "muted" | "muted-strong" | "none";
|
|
40
40
|
|
|
41
|
-
/** What a thread that needs
|
|
41
|
+
/** What a thread that needs attention is waiting for, from its note. */
|
|
42
42
|
export type NeedsKind = "question" | "approval" | "plan" | "input";
|
|
43
43
|
|
|
44
44
|
const NEEDS: Record<NeedsKind, { icon: string; label: string }> = {
|
|
@@ -26,14 +26,16 @@ export function toggleChip(row: ThreadRow, prefs: Preferences, forest: Forest):
|
|
|
26
26
|
// Expanding shows every child; collapsing also drops auto-reveals.
|
|
27
27
|
return expanded
|
|
28
28
|
? { patch: { expandedChildren: without(prefs.expandedChildren, id) }, drop: under(forest, id) }
|
|
29
|
-
:
|
|
29
|
+
: openChildren(id, prefs);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Opens a parent's children, as its chip does: "+N more" in Needs attention and an auto-reveal's fold. */
|
|
33
|
+
export function openChildren(parentId: string, prefs: Preferences): ToggleOutcome {
|
|
34
|
+
return { patch: { expandedChildren: [...without(prefs.expandedChildren, parentId), parentId] }, drop: null };
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
export function toggleOlder(row: OlderRow, prefs: Preferences, group: GroupView | null, forest: Forest): ToggleOutcome {
|
|
33
|
-
if (row.scope === "reveal")
|
|
34
|
-
// "+N more" expands the whole family, as the chip does.
|
|
35
|
-
return { patch: { expandedChildren: [...without(prefs.expandedChildren, row.scopeId), row.scopeId] }, drop: null };
|
|
36
|
-
}
|
|
38
|
+
if (row.scope === "reveal") return openChildren(row.scopeId, prefs);
|
|
37
39
|
if (!row.expanded) {
|
|
38
40
|
return { patch: { expandedOlder: [...without(prefs.expandedOlder, row.scopeId), row.scopeId] }, drop: null };
|
|
39
41
|
}
|