@gperezmz/bb-plugin-thread-glance 0.2.1 → 0.2.3
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 +135 -104
- package/features/thread-list/components/ThreadDetails.tsx +2 -3
- package/features/thread-list/components/ThreadList.tsx +12 -11
- package/features/thread-list/components/ThreadRowView.tsx +23 -19
- 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/useAttentionHold.ts +19 -0
- package/features/thread-list/model/{needs-you.ts → attention.ts} +48 -28
- 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 +96 -56
- package/features/thread-list/testing/fixtures.ts +8 -4
- 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
- package/features/thread-list/data/useNeedsYouHold.ts +0 -19
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Which family Needs attention holds, as it was when opened, and which family is open, while one of its
|
|
2
|
+
// threads is open. Diffed render to render, as auto-expansion is; nothing here is persisted.
|
|
3
|
+
import { useRef } from "react";
|
|
4
|
+
import type { Forest } from "../model/families";
|
|
5
|
+
import { holdAttention, NO_HOLD, type AttentionHold, type SectionFamily } from "../model/attention";
|
|
6
|
+
|
|
7
|
+
export function useAttentionHold(forest: Forest | null, activeThreadId: string | null): SectionFamily | null {
|
|
8
|
+
const hold = useRef<AttentionHold>(NO_HOLD);
|
|
9
|
+
const lastForest = useRef<Forest | null>(null);
|
|
10
|
+
const lastActive = useRef<string | null | undefined>(undefined);
|
|
11
|
+
// Derived during render, so the family never leaves for a frame. Idempotent:
|
|
12
|
+
// a repeated render with the same inputs keeps what it stored.
|
|
13
|
+
if (forest !== null && (forest !== lastForest.current || activeThreadId !== lastActive.current)) {
|
|
14
|
+
hold.current = holdAttention(hold.current, forest, activeThreadId);
|
|
15
|
+
lastForest.current = forest;
|
|
16
|
+
lastActive.current = activeThreadId;
|
|
17
|
+
}
|
|
18
|
+
return hold.current.held;
|
|
19
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
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";
|
|
5
5
|
import type { ChildAttention } from "@/shared/preferences";
|
|
6
|
-
import type { Family } from "./families";
|
|
6
|
+
import type { Family, Forest } 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,71 @@ 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
|
+
export type SectionFamily = Pick<Family, "root" | "attentionFlags" | "latestAttentionAt">;
|
|
94
94
|
|
|
95
|
-
/** What Needs
|
|
96
|
-
export interface
|
|
97
|
-
/**
|
|
98
|
-
|
|
95
|
+
/** What Needs attention remembers between renders: the family it holds, and the family that was open. */
|
|
96
|
+
export interface AttentionHold {
|
|
97
|
+
/**
|
|
98
|
+
* The family held in the section, if any, as it was when opened. The section
|
|
99
|
+
* orders it by this, so nothing that happens inside it moves it.
|
|
100
|
+
*/
|
|
101
|
+
held: SectionFamily | null;
|
|
99
102
|
/** The root of the family the open thread belonged to at the last render, if any. */
|
|
100
103
|
openRootId: string | null;
|
|
101
104
|
}
|
|
102
105
|
|
|
103
|
-
export const NO_HOLD:
|
|
106
|
+
export const NO_HOLD: AttentionHold = { held: null, openRootId: null };
|
|
104
107
|
|
|
105
108
|
/**
|
|
106
|
-
* Whether a family is in the Needs
|
|
107
|
-
* of its threads needs
|
|
109
|
+
* Whether a family is in the Needs attention section: it is the held family, or one
|
|
110
|
+
* of its threads needs attention and none of them is open. Nothing moves while you
|
|
108
111
|
* are inside a family, so the open family only enters by being judged when
|
|
109
|
-
* opened (see `
|
|
112
|
+
* opened (see `holdAttention`).
|
|
110
113
|
*/
|
|
111
|
-
export function
|
|
114
|
+
export function inAttention(family: SectionFamily, heldRootId: string | null, openRootId: string | null): boolean {
|
|
112
115
|
const rootId = family.root.thread.id;
|
|
113
116
|
if (rootId === heldRootId) return true;
|
|
114
|
-
return family.
|
|
117
|
+
return family.attentionFlags.size > 0 && rootId !== openRootId;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Whether a family is attended: held in the section with nothing in it needing
|
|
122
|
+
* attention any more. It keeps its place and draws and counts as in its home group.
|
|
123
|
+
*/
|
|
124
|
+
export function isAttended(family: SectionFamily, heldRootId: string | null): boolean {
|
|
125
|
+
return family.root.thread.id === heldRootId && family.attentionFlags.size === 0;
|
|
115
126
|
}
|
|
116
127
|
|
|
117
128
|
/**
|
|
118
129
|
* 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
|
|
130
|
+
* from outside it: it is held if something in it needs attention then, and it stays
|
|
131
|
+
* held while one of its threads is open, after nothing in it needs attention any
|
|
132
|
+
* more, until none of its threads is open. An attended family whose open thread
|
|
133
|
+
* is archived counts as closed. A family that does not need attention
|
|
122
134
|
* when opened is not pulled in later, whatever happens in it; nor is one
|
|
123
|
-
* that never needed
|
|
135
|
+
* that never needed attention. Opening another thread of the open family judges
|
|
124
136
|
* nothing again.
|
|
125
137
|
*/
|
|
126
|
-
export function
|
|
138
|
+
export function holdAttention(
|
|
139
|
+
previous: AttentionHold,
|
|
140
|
+
forest: Pick<Forest, "infos" | "familyOf">,
|
|
141
|
+
activeThreadId: string | null,
|
|
142
|
+
): AttentionHold {
|
|
143
|
+
if (activeThreadId === null) return NO_HOLD;
|
|
144
|
+
const openFamily = forest.familyOf.get(activeThreadId);
|
|
127
145
|
if (openFamily === undefined) return NO_HOLD;
|
|
128
146
|
const openRootId = openFamily.root.thread.id;
|
|
129
147
|
if (openRootId === previous.openRootId) {
|
|
130
|
-
|
|
148
|
+
const archived = forest.infos.get(activeThreadId)?.thread.isArchived ?? false;
|
|
149
|
+
const held = previous.held?.root.thread.id === openRootId && !(archived && openFamily.attentionFlags.size === 0);
|
|
150
|
+
return held ? previous : { held: null, openRootId };
|
|
131
151
|
}
|
|
132
|
-
return {
|
|
152
|
+
return { held: openFamily.attentionFlags.size > 0 ? openFamily : null, openRootId };
|
|
133
153
|
}
|
|
@@ -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
|
}
|