@gperezmz/bb-plugin-thread-glance 0.1.3 → 0.2.1
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 +32 -32
- package/dist/app.meta.json +1 -1
- package/dist/server.js +8 -8
- package/dist/server.js.map +3 -3
- package/dist/server.meta.json +1 -1
- package/features/thread-list/components/FoldRows.tsx +14 -13
- package/features/thread-list/components/GroupSection.tsx +118 -69
- package/features/thread-list/components/Rails.tsx +2 -3
- package/features/thread-list/components/ThreadList.tsx +21 -16
- package/features/thread-list/components/ThreadRowView.tsx +12 -9
- package/features/thread-list/components/Toolbar.tsx +129 -187
- package/features/thread-list/components/controller.ts +1 -2
- package/features/thread-list/components/glyphs.tsx +1 -1
- package/features/thread-list/data/useClientPreferences.ts +1 -1
- package/features/thread-list/data/useNeedsYouHold.ts +19 -0
- package/features/thread-list/model/counters.ts +13 -19
- package/features/thread-list/model/details.ts +2 -2
- package/features/thread-list/model/expansion.ts +10 -10
- package/features/thread-list/model/families.ts +50 -48
- package/features/thread-list/model/labels.ts +2 -1
- package/features/thread-list/model/layout.ts +6 -11
- package/features/thread-list/model/needs-you.ts +133 -0
- package/features/thread-list/model/notes.ts +1 -1
- package/features/thread-list/model/settings.ts +54 -0
- package/features/thread-list/model/share.ts +7 -1
- package/features/thread-list/model/sort.ts +14 -6
- package/features/thread-list/model/state.ts +11 -11
- package/features/thread-list/model/time.ts +1 -1
- package/features/thread-list/model/toggles.ts +2 -7
- package/features/thread-list/model/view.ts +139 -143
- package/features/thread-list/testing/fixtures.ts +19 -10
- package/package.json +1 -1
- package/server/cli.ts +2 -2
- package/shared/preferences.ts +10 -23
- package/skills/thread-glance/SKILL.md +9 -10
- package/features/thread-list/model/attention.ts +0 -89
|
@@ -12,13 +12,13 @@ import {
|
|
|
12
12
|
type StateKind,
|
|
13
13
|
type ThreadState,
|
|
14
14
|
} from "./state";
|
|
15
|
-
import {
|
|
15
|
+
import { needsYouFlagsOf, isOrphanedFailure } from "./needs-you";
|
|
16
16
|
import { compareCreationAscending } from "./sort";
|
|
17
17
|
import { needsKindOf, rowNote, type RowNote } from "./notes";
|
|
18
18
|
import type { ThreadNotes } from "@/shared/contract";
|
|
19
19
|
import type { ChildAttention } from "@/shared/preferences";
|
|
20
20
|
|
|
21
|
-
/** States that keep a child out of its family's fold with `childAttention` `blocked`: working, setting up, background work. */
|
|
21
|
+
/** States that keep a child out of its family's older fold with `childAttention` `blocked`: working, setting up, background work. */
|
|
22
22
|
const RUNNING: ReadonlySet<StateKind> = new Set<StateKind>(["working", "background"]);
|
|
23
23
|
|
|
24
24
|
/** Everything the list knows about one thread. */
|
|
@@ -26,24 +26,24 @@ export interface ThreadInfo {
|
|
|
26
26
|
thread: PluginSidebarThread;
|
|
27
27
|
state: ThreadState;
|
|
28
28
|
unread: boolean;
|
|
29
|
-
/** Own flags; for hidden threads only
|
|
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
|
|
32
|
+
* The flags of this thread that make it need you, for Needs you, the
|
|
33
33
|
* counters and auto-reveal: a child counts less than a root.
|
|
34
34
|
*/
|
|
35
|
-
|
|
35
|
+
needsYou: Set<Flag>;
|
|
36
36
|
/** Quiet test for the thread alone. */
|
|
37
37
|
quiet: boolean;
|
|
38
38
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* archived child is always
|
|
44
|
-
*
|
|
39
|
+
* A quiet thread, as if no thread were open: nothing to see behind a
|
|
40
|
+
* family's fold. A root, or any child when `childAttention` is
|
|
41
|
+
* `everything`, takes the quiet test without the open thread's exemption.
|
|
42
|
+
* Otherwise a child is quiet unless it runs or needs you, so finishing
|
|
43
|
+
* unread folds it; an archived child is always quiet. The fold reads this,
|
|
44
|
+
* so opening a thread never changes which children stay shown.
|
|
45
45
|
*/
|
|
46
|
-
|
|
46
|
+
quietIgnoringOpen: boolean;
|
|
47
47
|
isActive: boolean;
|
|
48
48
|
/** The visible ancestor it attaches to, or null for a root. */
|
|
49
49
|
parentId: string | null;
|
|
@@ -55,35 +55,35 @@ export interface ThreadInfo {
|
|
|
55
55
|
export interface Subtree {
|
|
56
56
|
/** Descendants, depth-first in creation order, hidden ones included. */
|
|
57
57
|
descendants: ThreadInfo[];
|
|
58
|
-
/** Union of the descendants'
|
|
58
|
+
/** Union of the descendants' Needs you flags and working, archived ones left out. */
|
|
59
59
|
flags: ReadonlySet<Flag>;
|
|
60
60
|
/** Visible descendants: the chip's number. */
|
|
61
61
|
visibleCount: number;
|
|
62
|
-
/** Every visible descendant is
|
|
63
|
-
|
|
62
|
+
/** Every visible descendant is quiet, as if no thread were open, so the subtree adds nothing to see when folded. */
|
|
63
|
+
quietIgnoringOpen: boolean;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
export interface Family {
|
|
67
67
|
root: ThreadInfo;
|
|
68
68
|
/** Descendants, depth-first in creation order, hidden ones included. */
|
|
69
69
|
descendants: ThreadInfo[];
|
|
70
|
-
/** Union of the descendants'
|
|
70
|
+
/** Union of the descendants' Needs you flags and working (the chip rollup). */
|
|
71
71
|
descendantFlags: ReadonlySet<Flag>;
|
|
72
72
|
/** The same over root and descendants (rollup, folder rows). */
|
|
73
73
|
flags: ReadonlySet<Flag>;
|
|
74
|
-
/**
|
|
75
|
-
|
|
74
|
+
/** Needs you flags over root and descendants: what the section and its order read. */
|
|
75
|
+
needsYouFlags: ReadonlySet<Flag>;
|
|
76
76
|
/** Visible descendants: the chip's number. */
|
|
77
77
|
visibleDescendantCount: number;
|
|
78
78
|
/** Largest latestAttentionAt over the family. */
|
|
79
|
-
|
|
79
|
+
latestAttentionAt: number;
|
|
80
80
|
/** Every thread quiet and the active thread not in it. */
|
|
81
81
|
quiet: boolean;
|
|
82
82
|
/**
|
|
83
83
|
* The quiet test without the open thread's exemption. A group's fold reads
|
|
84
84
|
* this, so opening a thread never changes which roots stay shown.
|
|
85
85
|
*/
|
|
86
|
-
|
|
86
|
+
quietIgnoringOpen: boolean;
|
|
87
87
|
containsActive: boolean;
|
|
88
88
|
}
|
|
89
89
|
|
|
@@ -104,7 +104,7 @@ export interface ForestInputs extends ThreadContext {
|
|
|
104
104
|
now: number;
|
|
105
105
|
/** Server notes per thread id; absent until loaded. */
|
|
106
106
|
notes?: Readonly<Record<string, ThreadNotes>>;
|
|
107
|
-
/** Which children
|
|
107
|
+
/** Which children can need you; `blocked` when absent. */
|
|
108
108
|
childAttention?: ChildAttention;
|
|
109
109
|
}
|
|
110
110
|
|
|
@@ -159,9 +159,9 @@ export function buildForest(inputs: ForestInputs): Forest {
|
|
|
159
159
|
state,
|
|
160
160
|
unread,
|
|
161
161
|
flags: thread.isHidden ? hiddenThreadFlags(flags) : flags,
|
|
162
|
-
|
|
162
|
+
needsYou: new Set<Flag>(),
|
|
163
163
|
quiet: isQuietThread(state, unread, isActive),
|
|
164
|
-
|
|
164
|
+
quietIgnoringOpen: false,
|
|
165
165
|
isActive,
|
|
166
166
|
parentId: attachParent(thread, byId),
|
|
167
167
|
note: rowNote(thread, inputs.notes?.[thread.id]),
|
|
@@ -170,18 +170,18 @@ export function buildForest(inputs: ForestInputs): Forest {
|
|
|
170
170
|
|
|
171
171
|
const mode = inputs.childAttention ?? "blocked";
|
|
172
172
|
for (const info of infos.values()) {
|
|
173
|
-
const
|
|
174
|
-
info.
|
|
175
|
-
isRoot:
|
|
173
|
+
const parent = info.parentId === null ? undefined : infos.get(info.parentId);
|
|
174
|
+
info.needsYou = needsYouFlagsOf(info.flags, {
|
|
175
|
+
isRoot: parent === undefined,
|
|
176
176
|
mode,
|
|
177
177
|
orphaned:
|
|
178
|
-
|
|
179
|
-
isOrphanedFailure(info.thread, info.flags, { ...
|
|
178
|
+
parent !== undefined &&
|
|
179
|
+
isOrphanedFailure(info.thread, info.flags, { ...parent, finishedAt: inputs.finishedAt[parent.thread.id] }),
|
|
180
180
|
});
|
|
181
|
-
info.
|
|
182
|
-
|
|
181
|
+
info.quietIgnoringOpen =
|
|
182
|
+
parent === undefined || mode === "everything"
|
|
183
183
|
? isQuietThread(info.state, info.unread, false)
|
|
184
|
-
: info.thread.isArchived || (!RUNNING.has(info.state.kind) && info.
|
|
184
|
+
: info.thread.isArchived || (!RUNNING.has(info.state.kind) && info.needsYou.size === 0);
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
const children = new Map<string, string[]>();
|
|
@@ -208,7 +208,7 @@ export function buildForest(inputs: ForestInputs): Forest {
|
|
|
208
208
|
const descendants: ThreadInfo[] = [];
|
|
209
209
|
const flags = new Set<Flag>();
|
|
210
210
|
let visibleCount = 0;
|
|
211
|
-
let
|
|
211
|
+
let quietIgnoringOpen = true;
|
|
212
212
|
path.add(id);
|
|
213
213
|
for (const childId of children.get(id) ?? []) {
|
|
214
214
|
if (path.has(childId)) continue;
|
|
@@ -216,17 +216,17 @@ export function buildForest(inputs: ForestInputs): Forest {
|
|
|
216
216
|
const below = subtreeOf(childId, path);
|
|
217
217
|
descendants.push(child, ...below.descendants);
|
|
218
218
|
if (!child.thread.isArchived) {
|
|
219
|
-
for (const flag of child.
|
|
219
|
+
for (const flag of child.needsYou) flags.add(flag);
|
|
220
220
|
if (child.flags.has("working")) flags.add("working");
|
|
221
221
|
}
|
|
222
222
|
for (const flag of below.flags) flags.add(flag);
|
|
223
|
-
if (child.thread.isHidden ? child.
|
|
223
|
+
if (child.thread.isHidden ? child.needsYou.size > 0 : !child.quietIgnoringOpen) quietIgnoringOpen = false;
|
|
224
224
|
if (!child.thread.isHidden) visibleCount += 1;
|
|
225
225
|
visibleCount += below.visibleCount;
|
|
226
|
-
if (!below.
|
|
226
|
+
if (!below.quietIgnoringOpen) quietIgnoringOpen = false;
|
|
227
227
|
}
|
|
228
228
|
path.delete(id);
|
|
229
|
-
const subtree: Subtree = { descendants, flags, visibleCount,
|
|
229
|
+
const subtree: Subtree = { descendants, flags, visibleCount, quietIgnoringOpen };
|
|
230
230
|
subtrees.set(id, subtree);
|
|
231
231
|
return subtree;
|
|
232
232
|
};
|
|
@@ -236,27 +236,29 @@ export function buildForest(inputs: ForestInputs): Forest {
|
|
|
236
236
|
const familyOf = new Map<string, Family>();
|
|
237
237
|
for (const root of roots) {
|
|
238
238
|
const { descendants, flags: descendantFlags, visibleCount: visibleDescendantCount } = subtrees.get(root.thread.id)!;
|
|
239
|
-
let
|
|
239
|
+
let latestAttentionAt = root.thread.latestAttentionAt;
|
|
240
240
|
let quiet = root.quiet;
|
|
241
|
-
let
|
|
241
|
+
let quietIgnoringOpen = isQuietThread(root.state, root.unread, false);
|
|
242
242
|
let containsActive = root.isActive;
|
|
243
|
-
const
|
|
243
|
+
const needsYouFlags = new Set<Flag>();
|
|
244
244
|
for (const info of descendants) {
|
|
245
|
-
if (!info.thread.isArchived) for (const flag of info.
|
|
246
|
-
|
|
245
|
+
if (!info.thread.isArchived) for (const flag of info.needsYou) needsYouFlags.add(flag);
|
|
246
|
+
latestAttentionAt = Math.max(latestAttentionAt, info.thread.latestAttentionAt);
|
|
247
247
|
if (info.isActive) containsActive = true;
|
|
248
248
|
if (info.thread.isHidden) {
|
|
249
|
-
if (info.
|
|
249
|
+
if (info.needsYou.size > 0) quiet = quietIgnoringOpen = false;
|
|
250
250
|
} else {
|
|
251
251
|
if (!info.quiet) quiet = false;
|
|
252
|
-
|
|
252
|
+
// A child reads as its own fold does, so with `blocked` an unread child
|
|
253
|
+
// does not keep an old family out of the group's fold.
|
|
254
|
+
if (!info.quietIgnoringOpen) quietIgnoringOpen = false;
|
|
253
255
|
}
|
|
254
256
|
}
|
|
255
257
|
const flags = new Set<Flag>(descendantFlags);
|
|
256
258
|
if (!root.thread.isArchived) {
|
|
257
|
-
for (const flag of root.
|
|
259
|
+
for (const flag of root.needsYou) {
|
|
258
260
|
flags.add(flag);
|
|
259
|
-
|
|
261
|
+
needsYouFlags.add(flag);
|
|
260
262
|
}
|
|
261
263
|
if (root.flags.has("working")) flags.add("working");
|
|
262
264
|
}
|
|
@@ -265,11 +267,11 @@ export function buildForest(inputs: ForestInputs): Forest {
|
|
|
265
267
|
descendants,
|
|
266
268
|
descendantFlags,
|
|
267
269
|
flags,
|
|
268
|
-
|
|
270
|
+
needsYouFlags,
|
|
269
271
|
visibleDescendantCount,
|
|
270
|
-
|
|
272
|
+
latestAttentionAt,
|
|
271
273
|
quiet: quiet && !containsActive,
|
|
272
|
-
|
|
274
|
+
quietIgnoringOpen,
|
|
273
275
|
containsActive,
|
|
274
276
|
};
|
|
275
277
|
families.push(family);
|
|
@@ -12,7 +12,7 @@ export function stateText(row: ThreadRow, pluginLabel: string | null): string {
|
|
|
12
12
|
return base;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
/** "Open Fix login — Working; Claude Code; child of
|
|
15
|
+
/** "Open Fix login — Working; Claude Code; child of Release; unread". */
|
|
16
16
|
export function rowAriaLabel(
|
|
17
17
|
row: ThreadRow,
|
|
18
18
|
options: { providerName: string; pluginLabel: string | null; hasDraft: boolean },
|
|
@@ -20,6 +20,7 @@ export function rowAriaLabel(
|
|
|
20
20
|
const parts = [stateText(row, options.pluginLabel), options.providerName];
|
|
21
21
|
if (row.parentTitle !== null && row.depth > 0) parts.push(`child of ${row.parentTitle}`);
|
|
22
22
|
if (row.crossGroupLabel !== null) parts.push(row.crossGroupLabel.toLowerCase());
|
|
23
|
+
if (row.homeGroupLabel !== null) parts.push(`in ${row.homeGroupLabel}`);
|
|
23
24
|
if (row.hiddenBadge) parts.push("hidden thread");
|
|
24
25
|
if (row.info.unread && row.info.state.kind !== "unread") parts.push("unread");
|
|
25
26
|
if (options.hasDraft && row.info.state.kind !== "draft") parts.push("unsubmitted draft");
|
|
@@ -1,25 +1,20 @@
|
|
|
1
1
|
// How far a row sits from the list's left edge. Pure, so a test can
|
|
2
2
|
// call it without rendering.
|
|
3
|
-
import type { Nesting } from "@/shared/preferences";
|
|
4
|
-
|
|
5
3
|
/** Left padding of a root row, as in bb's list. */
|
|
6
4
|
export const ROOT_INDENT = 8;
|
|
7
|
-
/** Tree nesting keeps bb's step per level. */
|
|
8
|
-
export const TREE_STEP = 24;
|
|
9
5
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* parent's title.
|
|
6
|
+
* A child's status slot starts this far right of its parent's, so the
|
|
7
|
+
* child's title sits this far right of the parent's title.
|
|
13
8
|
*/
|
|
14
9
|
export const FOLDED_STEP = 12;
|
|
15
10
|
|
|
16
|
-
export function rowIndent(depth: number
|
|
17
|
-
return ROOT_INDENT + depth *
|
|
11
|
+
export function rowIndent(depth: number): number {
|
|
12
|
+
return ROOT_INDENT + depth * FOLDED_STEP;
|
|
18
13
|
}
|
|
19
14
|
|
|
20
15
|
/** How far the rail sits from a row's left edge: the centre of the status slot at that level. */
|
|
21
|
-
export function railLeft(level: number
|
|
22
|
-
return rowIndent(level
|
|
16
|
+
export function railLeft(level: number): number {
|
|
17
|
+
return rowIndent(level) + 8;
|
|
23
18
|
}
|
|
24
19
|
|
|
25
20
|
/**
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// What needs you, thread by thread. The Needs you section, the header
|
|
2
|
+
// counters, the section's order and auto-reveal all read the set this
|
|
3
|
+
// builds. Pure.
|
|
4
|
+
import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
|
|
5
|
+
import type { ChildAttention } from "@/shared/preferences";
|
|
6
|
+
import type { Family } from "./families";
|
|
7
|
+
import { type Flag, type StateKind, type ThreadState } from "./state";
|
|
8
|
+
|
|
9
|
+
/** The flags that count for a root thread. Working is not one. */
|
|
10
|
+
export const ROOT_NEEDS_YOU: ReadonlySet<Flag> = new Set<Flag>([
|
|
11
|
+
"waits-on-you",
|
|
12
|
+
"unread-failed",
|
|
13
|
+
"queue-failed",
|
|
14
|
+
"offline",
|
|
15
|
+
"unread",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
/** States in which a parent thread is busy and may still deal with a failed child. */
|
|
19
|
+
const BUSY: ReadonlySet<StateKind> = new Set<StateKind>(["working", "background", "queued", "scheduled"]);
|
|
20
|
+
|
|
21
|
+
export interface ParentThread {
|
|
22
|
+
thread: Pick<PluginSidebarThread, "latestAttentionAt">;
|
|
23
|
+
state: Pick<ThreadState, "kind">;
|
|
24
|
+
/** When the plugin server last saw the parent thread finish a turn, if it did. */
|
|
25
|
+
finishedAt?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A parent thread is idle when it is not working, setting up, running background work or holding a queued message. */
|
|
29
|
+
export function isParentIdle(parent: ParentThread): boolean {
|
|
30
|
+
return !BUSY.has(parent.state.kind);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** When the thread's failure happened: an error bumps `latestAttentionAt`, a failed queue only `updatedAt`. */
|
|
34
|
+
export function failureTime(
|
|
35
|
+
thread: Pick<PluginSidebarThread, "latestAttentionAt" | "updatedAt">,
|
|
36
|
+
flags: ReadonlySet<Flag>,
|
|
37
|
+
): number {
|
|
38
|
+
let at = 0;
|
|
39
|
+
if (flags.has("unread-failed")) at = Math.max(at, thread.latestAttentionAt);
|
|
40
|
+
if (flags.has("queue-failed")) at = Math.max(at, thread.updatedAt);
|
|
41
|
+
return at;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* An orphaned failure: the parent thread is idle and has not run or
|
|
46
|
+
* finished since the child failed. A busy parent thread, or one that
|
|
47
|
+
* finished after the failure, is taken to know about it. The parent thread's
|
|
48
|
+
* own clock is its last finish (`finishedAt`, or `latestAttentionAt` when it
|
|
49
|
+
* failed): `updatedAt` also moves when someone merely opens or renames it.
|
|
50
|
+
*/
|
|
51
|
+
export function isOrphanedFailure(
|
|
52
|
+
thread: Pick<PluginSidebarThread, "latestAttentionAt" | "updatedAt">,
|
|
53
|
+
flags: ReadonlySet<Flag>,
|
|
54
|
+
parent: ParentThread,
|
|
55
|
+
): boolean {
|
|
56
|
+
if (!flags.has("unread-failed") && !flags.has("queue-failed")) return false;
|
|
57
|
+
if (!isParentIdle(parent)) return false;
|
|
58
|
+
const parentActiveAt = Math.max(parent.thread.latestAttentionAt, parent.finishedAt ?? 0);
|
|
59
|
+
return parentActiveAt <= failureTime(thread, flags);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The flags of one thread that make it need you. A root keeps its own. A
|
|
64
|
+
* child counts when it waits on you or is offline, or for an orphaned
|
|
65
|
+
* failure; a finished-unread child never does. With `everything`, a child
|
|
66
|
+
* counts like a root.
|
|
67
|
+
*/
|
|
68
|
+
export function needsYouFlagsOf(
|
|
69
|
+
flags: ReadonlySet<Flag>,
|
|
70
|
+
options: { isRoot: boolean; mode: ChildAttention; orphaned: boolean },
|
|
71
|
+
): Set<Flag> {
|
|
72
|
+
const kept = new Set<Flag>();
|
|
73
|
+
if (options.isRoot || options.mode === "everything") {
|
|
74
|
+
for (const flag of flags) if (ROOT_NEEDS_YOU.has(flag)) kept.add(flag);
|
|
75
|
+
return kept;
|
|
76
|
+
}
|
|
77
|
+
if (flags.has("waits-on-you")) kept.add("waits-on-you");
|
|
78
|
+
if (flags.has("offline")) kept.add("offline");
|
|
79
|
+
if (options.orphaned) {
|
|
80
|
+
if (flags.has("unread-failed")) kept.add("unread-failed");
|
|
81
|
+
if (flags.has("queue-failed")) kept.add("queue-failed");
|
|
82
|
+
}
|
|
83
|
+
return kept;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Whether a set of Needs you flags is worth an auto-reveal: it waits on you or failed. */
|
|
87
|
+
export function revealsOn(needsYou: ReadonlySet<Flag>, isRoot: boolean): boolean {
|
|
88
|
+
if (needsYou.has("waits-on-you") || needsYou.has("unread-failed")) return true;
|
|
89
|
+
return !isRoot && needsYou.has("queue-failed");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** What the section reads of a family. */
|
|
93
|
+
type SectionFamily = Pick<Family, "root" | "needsYouFlags">;
|
|
94
|
+
|
|
95
|
+
/** What Needs you remembers between renders: the family it holds, and the family that was open. */
|
|
96
|
+
export interface NeedsYouHold {
|
|
97
|
+
/** The root of the family held in the section, if any. */
|
|
98
|
+
heldRootId: string | null;
|
|
99
|
+
/** The root of the family the open thread belonged to at the last render, if any. */
|
|
100
|
+
openRootId: string | null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const NO_HOLD: NeedsYouHold = { heldRootId: null, openRootId: null };
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether a family is in the Needs you section: it is the held family, or one
|
|
107
|
+
* of its threads needs you and none of them is open. Nothing moves while you
|
|
108
|
+
* are inside a family, so the open family only enters by being judged when
|
|
109
|
+
* opened (see `holdNeedsYou`).
|
|
110
|
+
*/
|
|
111
|
+
export function inNeedsYou(family: SectionFamily, heldRootId: string | null, openRootId: string | null): boolean {
|
|
112
|
+
const rootId = family.root.thread.id;
|
|
113
|
+
if (rootId === heldRootId) return true;
|
|
114
|
+
return family.needsYouFlags.size > 0 && rootId !== openRootId;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
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 you then, and it stays
|
|
120
|
+
* held while one of its threads is open, after nothing in it needs you any
|
|
121
|
+
* more, until a thread outside it is opened. A family that does not need you
|
|
122
|
+
* when opened is not pulled in later, whatever happens in it; nor is one
|
|
123
|
+
* that never needed you. Opening another thread of the open family judges
|
|
124
|
+
* nothing again.
|
|
125
|
+
*/
|
|
126
|
+
export function holdNeedsYou(previous: NeedsYouHold, openFamily: SectionFamily | undefined): NeedsYouHold {
|
|
127
|
+
if (openFamily === undefined) return NO_HOLD;
|
|
128
|
+
const openRootId = openFamily.root.thread.id;
|
|
129
|
+
if (openRootId === previous.openRootId) {
|
|
130
|
+
return { heldRootId: previous.heldRootId === openRootId ? openRootId : null, openRootId };
|
|
131
|
+
}
|
|
132
|
+
return { heldRootId: openFamily.needsYouFlags.size > 0 ? openRootId : null, openRootId };
|
|
133
|
+
}
|
|
@@ -18,7 +18,7 @@ const PENDING_PREFIX: Record<NeedsKind, string> = {
|
|
|
18
18
|
input: "Needs",
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
-
/**
|
|
21
|
+
/** What a pending note says the thread waits on you for, or null when there is none. */
|
|
22
22
|
export function needsKindOf(notes: ThreadNotes | undefined): NeedsKind | null {
|
|
23
23
|
const kind = notes?.pending?.kind;
|
|
24
24
|
return kind === "question" || kind === "approval" || kind === "plan" || kind === "input" ? kind : null;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// What the settings popover's controls show and write. Pure.
|
|
2
|
+
import type { ChildAttention, Lifecycle, Preferences, SortField } from "@/shared/preferences";
|
|
3
|
+
import { effectiveDirection, effectiveSortField, naturalDirection } from "./sort";
|
|
4
|
+
|
|
5
|
+
export type ThreadsShown = "active" | "archived" | "both";
|
|
6
|
+
|
|
7
|
+
/** The Threads choice a saved lifecycle list stands for. */
|
|
8
|
+
export function threadsShown(lifecycles: readonly Lifecycle[]): ThreadsShown {
|
|
9
|
+
const active = lifecycles.includes("active");
|
|
10
|
+
const archived = lifecycles.includes("archived");
|
|
11
|
+
if (active && archived) return "both";
|
|
12
|
+
return archived ? "archived" : "active";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The lifecycles a Threads choice saves; never empty. */
|
|
16
|
+
export function lifecyclesFor(shown: ThreadsShown): Lifecycle[] {
|
|
17
|
+
if (shown === "both") return ["active", "archived"];
|
|
18
|
+
return [shown];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** "Needs you counts every child" is on when children count as `everything`. */
|
|
22
|
+
export function countsEveryChild(childAttention: ChildAttention): boolean {
|
|
23
|
+
return childAttention === "everything";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function childAttentionFor(everyChild: boolean): ChildAttention {
|
|
27
|
+
return everyChild ? "everything" : "blocked";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Choosing a field starts it in its own direction, as a saved `default` did. */
|
|
31
|
+
export function sortFieldPatch(field: Exclude<SortField, "none">): Partial<Preferences> {
|
|
32
|
+
return { chronologicalSort: field, sortDirection: naturalDirection(field) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SortArrow {
|
|
36
|
+
glyph: "↓" | "↑";
|
|
37
|
+
/** The order the list is in now, in words. */
|
|
38
|
+
label: string;
|
|
39
|
+
/** What pressing the arrow saves. */
|
|
40
|
+
patch: Partial<Preferences>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The ↓/↑ button: ↓ descending (newest first, Z–A), ↑ ascending. */
|
|
44
|
+
export function sortArrow(prefs: Pick<Preferences, "chronologicalSort" | "sortDirection">): SortArrow {
|
|
45
|
+
const field = effectiveSortField(prefs.chronologicalSort);
|
|
46
|
+
const direction = effectiveDirection(field, prefs.sortDirection);
|
|
47
|
+
const descending = direction === "descending";
|
|
48
|
+
const label = field === "alpha" ? (descending ? "Z–A" : "A–Z") : descending ? "Newest first" : "Oldest first";
|
|
49
|
+
return {
|
|
50
|
+
glyph: descending ? "↓" : "↑",
|
|
51
|
+
label,
|
|
52
|
+
patch: { sortDirection: descending ? "ascending" : "descending" },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -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, Row } from "./view";
|
|
3
|
+
import type { GroupView, ListView, NeedsYouView, 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,6 +86,11 @@ function shareGroups(previousGroups: GroupView[], all: ReadonlyMap<string, Group
|
|
|
86
86
|
return sameItems(previousGroups, result) ? previousGroups : result;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
function shareNeedsYou(previous: NeedsYouView | null, next: NeedsYouView | null): NeedsYouView | null {
|
|
90
|
+
if (previous === null || next === null) return next;
|
|
91
|
+
return shareFields(previous, next, { rows: shareRows(previous.rows, next.rows) });
|
|
92
|
+
}
|
|
93
|
+
|
|
89
94
|
/**
|
|
90
95
|
* `next`, sharing every group and row that did not change since `previous`.
|
|
91
96
|
* Groups match by id and rows by key, never by position. A group keeps its
|
|
@@ -95,6 +100,7 @@ export function shareView(previous: ListView | null, next: ListView): ListView {
|
|
|
95
100
|
if (previous === null) return next;
|
|
96
101
|
const all = new Map([...previous.groups, ...previous.more].map((group) => [group.descriptor.id, group]));
|
|
97
102
|
return shareFields(previous, next, {
|
|
103
|
+
needsYou: shareNeedsYou(previous.needsYou, next.needsYou),
|
|
98
104
|
groups: shareGroups(previous.groups, all, next.groups),
|
|
99
105
|
more: shareGroups(previous.more, all, next.more),
|
|
100
106
|
});
|
|
@@ -38,15 +38,23 @@ export function effectiveSortField(field: SortField): Exclude<SortField, "none">
|
|
|
38
38
|
return field === "none" ? "updated" : field;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** A field's own direction: A–Z for names, newest first for dates. */
|
|
42
|
+
export function naturalDirection(field: SortField): "ascending" | "descending" {
|
|
43
|
+
return effectiveSortField(field) === "alpha" ? "ascending" : "descending";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The direction the list is sorted in: a saved `default` reads as the field's own. */
|
|
47
|
+
export function effectiveDirection(field: SortField, direction: SortDirection): "ascending" | "descending" {
|
|
48
|
+
return direction === "default" ? naturalDirection(field) : direction;
|
|
49
|
+
}
|
|
50
|
+
|
|
41
51
|
/**
|
|
42
|
-
* The comparator for roots in a group.
|
|
43
|
-
*
|
|
44
|
-
* Working first keeps active threads on top whatever the direction.
|
|
52
|
+
* The comparator for roots in a group. Working first keeps active threads on
|
|
53
|
+
* top whatever the direction.
|
|
45
54
|
*/
|
|
46
55
|
export function makeComparator(options: SortOptions): (a: SortKey, b: SortKey) => number {
|
|
47
56
|
const field = effectiveSortField(options.field);
|
|
48
|
-
const
|
|
49
|
-
const sign = options.direction === "default" || options.direction === natural ? 1 : -1;
|
|
57
|
+
const sign = effectiveDirection(field, options.direction) === naturalDirection(field) ? 1 : -1;
|
|
50
58
|
const base = field === "alpha" ? byAlpha : field === "created" ? byCreated : byUpdated;
|
|
51
59
|
return (a, b) => {
|
|
52
60
|
if (field === "updated" && options.workingFirst) {
|
|
@@ -70,7 +78,7 @@ export function comparePinned(a: PluginSidebarThread, b: PluginSidebarThread): n
|
|
|
70
78
|
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
71
79
|
}
|
|
72
80
|
|
|
73
|
-
/** Children
|
|
81
|
+
/** Children under a chip: creation order. */
|
|
74
82
|
export function compareCreationAscending(a: PluginSidebarThread, b: PluginSidebarThread): number {
|
|
75
83
|
const diff = a.createdAt - b.createdAt;
|
|
76
84
|
if (diff !== 0) return diff;
|
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
} from "@get-bb/plugin-sdk/app";
|
|
7
7
|
|
|
8
8
|
export type StateKind =
|
|
9
|
-
| "
|
|
9
|
+
| "waits-on-you"
|
|
10
10
|
| "failed"
|
|
11
11
|
| "queue-failed"
|
|
12
12
|
| "offline"
|
|
@@ -19,11 +19,11 @@ export type StateKind =
|
|
|
19
19
|
| "idle";
|
|
20
20
|
|
|
21
21
|
/** A rollup flag. Set independently of the first-match state. */
|
|
22
|
-
export type Flag = "
|
|
22
|
+
export type Flag = "waits-on-you" | "unread-failed" | "queue-failed" | "offline" | "working" | "unread";
|
|
23
23
|
|
|
24
24
|
/** Flags, most urgent first. */
|
|
25
25
|
export const FLAG_ORDER: readonly Flag[] = [
|
|
26
|
-
"
|
|
26
|
+
"waits-on-you",
|
|
27
27
|
"unread-failed",
|
|
28
28
|
"queue-failed",
|
|
29
29
|
"offline",
|
|
@@ -179,7 +179,7 @@ export function computeState(thread: PluginSidebarThread, inputs: StateInputs):
|
|
|
179
179
|
if (thread.hasPendingInteraction) {
|
|
180
180
|
const needs = NEEDS[inputs.needsKind ?? "input"] ?? NEEDS.input;
|
|
181
181
|
const icon = inputs.needsKind ? needs.icon : "CircleQuestion";
|
|
182
|
-
return { kind: "
|
|
182
|
+
return { kind: "waits-on-you", label: needs.label, glyph: glyph(icon, "attention"), ...none };
|
|
183
183
|
}
|
|
184
184
|
if (status === "error") {
|
|
185
185
|
return {
|
|
@@ -236,14 +236,14 @@ export function computeState(thread: PluginSidebarThread, inputs: StateInputs):
|
|
|
236
236
|
|
|
237
237
|
/**
|
|
238
238
|
* A plugin row status replaces the glyph for every state except
|
|
239
|
-
*
|
|
239
|
+
* waits-on-you, failed, and working with its plain spinner (the rule bb's list uses).
|
|
240
240
|
*/
|
|
241
241
|
export function pluginStatusWins(
|
|
242
242
|
state: ThreadState,
|
|
243
243
|
rowStatus: PluginSidebarThreadRowStatus | null,
|
|
244
244
|
): boolean {
|
|
245
245
|
if (rowStatus === null) return false;
|
|
246
|
-
if (state.kind === "
|
|
246
|
+
if (state.kind === "waits-on-you" || state.kind === "failed") return false;
|
|
247
247
|
if (state.kind === "working" && state.glyph.spin) return false;
|
|
248
248
|
return true;
|
|
249
249
|
}
|
|
@@ -251,7 +251,7 @@ export function pluginStatusWins(
|
|
|
251
251
|
/** The rollup flags a single thread carries. */
|
|
252
252
|
export function threadFlags(thread: PluginSidebarThread, unread: boolean): Set<Flag> {
|
|
253
253
|
const flags = new Set<Flag>();
|
|
254
|
-
if (thread.hasPendingInteraction) flags.add("
|
|
254
|
+
if (thread.hasPendingInteraction) flags.add("waits-on-you");
|
|
255
255
|
if (normalizeStatus(thread) === "error" && unread) flags.add("unread-failed");
|
|
256
256
|
if (normalizeQueued(thread) === "failed") flags.add("queue-failed");
|
|
257
257
|
if (isOffline(thread)) flags.add("offline");
|
|
@@ -260,10 +260,10 @@ export function threadFlags(thread: PluginSidebarThread, unread: boolean): Set<F
|
|
|
260
260
|
return flags;
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
-
/** Flags hidden threads contribute:
|
|
263
|
+
/** Flags hidden threads contribute: waits-on-you and unread-failed only. */
|
|
264
264
|
export function hiddenThreadFlags(flags: ReadonlySet<Flag>): Set<Flag> {
|
|
265
265
|
const kept = new Set<Flag>();
|
|
266
|
-
if (flags.has("
|
|
266
|
+
if (flags.has("waits-on-you")) kept.add("waits-on-you");
|
|
267
267
|
if (flags.has("unread-failed")) kept.add("unread-failed");
|
|
268
268
|
return kept;
|
|
269
269
|
}
|
|
@@ -279,7 +279,7 @@ export type ChipTone = "attention" | "destructive" | "working" | "neutral";
|
|
|
279
279
|
/** The tone of a chip whose most urgent rolled-up flag is `flag`. */
|
|
280
280
|
export function chipTone(flag: Flag | null): ChipTone {
|
|
281
281
|
switch (flag) {
|
|
282
|
-
case "
|
|
282
|
+
case "waits-on-you":
|
|
283
283
|
return "attention";
|
|
284
284
|
case "unread-failed":
|
|
285
285
|
case "queue-failed":
|
|
@@ -292,7 +292,7 @@ export function chipTone(flag: Flag | null): ChipTone {
|
|
|
292
292
|
}
|
|
293
293
|
|
|
294
294
|
export const FLAG_GLYPHS: Readonly<Record<Flag, Glyph & { label: string }>> = {
|
|
295
|
-
"
|
|
295
|
+
"waits-on-you": { ...glyph("CircleQuestion", "attention"), label: "needs your input" },
|
|
296
296
|
"unread-failed": { ...glyph("CircleX", "destructive"), label: "failed" },
|
|
297
297
|
"queue-failed": { ...glyph("AlertTriangle", "destructive"), label: "queued message failed" },
|
|
298
298
|
offline: { ...glyph("CloudOff", "attention"), label: "machine offline" },
|
|
@@ -97,7 +97,7 @@ export function stateSince(
|
|
|
97
97
|
const at =
|
|
98
98
|
kind === "working"
|
|
99
99
|
? stamps.startedAt[threadId]
|
|
100
|
-
: kind === "
|
|
100
|
+
: kind === "waits-on-you"
|
|
101
101
|
? stamps.pendingAt[threadId]
|
|
102
102
|
: kind === "idle" || kind === "unread" || kind === "failed"
|
|
103
103
|
? stamps.finishedAt[threadId]
|