@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
|
@@ -23,12 +23,7 @@ function under(forest: Forest, parentId: string): (id: string) => boolean {
|
|
|
23
23
|
export function toggleChip(row: ThreadRow, prefs: Preferences, forest: Forest): ToggleOutcome {
|
|
24
24
|
const id = row.info.thread.id;
|
|
25
25
|
const expanded = row.chip?.expanded ?? false;
|
|
26
|
-
|
|
27
|
-
return expanded
|
|
28
|
-
? { patch: { collapsedChildren: [...without(prefs.collapsedChildren, id), id] }, drop: under(forest, id) }
|
|
29
|
-
: { patch: { collapsedChildren: without(prefs.collapsedChildren, id) }, drop: null };
|
|
30
|
-
}
|
|
31
|
-
// Folded: expanding shows every child; collapsing also drops auto-reveals.
|
|
26
|
+
// Expanding shows every child; collapsing also drops auto-reveals.
|
|
32
27
|
return expanded
|
|
33
28
|
? { patch: { expandedChildren: without(prefs.expandedChildren, id) }, drop: under(forest, id) }
|
|
34
29
|
: { patch: { expandedChildren: [...without(prefs.expandedChildren, id), id] }, drop: null };
|
|
@@ -46,7 +41,7 @@ export function toggleOlder(row: OlderRow, prefs: Preferences, group: GroupView
|
|
|
46
41
|
if (row.scope === "family") return { patch, drop: under(forest, row.scopeId) };
|
|
47
42
|
// Folding a group's older roots drops the targets that held it open.
|
|
48
43
|
const quietRoots = new Set<string>();
|
|
49
|
-
for (const family of forest.families) if (family.
|
|
44
|
+
for (const family of forest.families) if (family.quietIgnoringOpen) quietRoots.add(family.root.thread.id);
|
|
50
45
|
const inGroup = new Set(group?.rootIds ?? []);
|
|
51
46
|
return {
|
|
52
47
|
patch,
|
|
@@ -20,21 +20,20 @@ import {
|
|
|
20
20
|
} from "./groups";
|
|
21
21
|
import { addCounters, countFamilies, EMPTY_COUNTERS, type Counters } from "./counters";
|
|
22
22
|
import { comparePinned, effectiveSortField, makeComparator, type SortKey } from "./sort";
|
|
23
|
+
import { inNeedsYou } from "./needs-you";
|
|
23
24
|
import { mostUrgent, type Flag } from "./state";
|
|
24
25
|
import type { Targets } from "./expansion";
|
|
25
26
|
import { railsFor, type Rail } from "./layout";
|
|
26
27
|
|
|
27
|
-
/** How many quiet roots stay
|
|
28
|
+
/** How many of a group's newest quiet roots stay out of its older fold. */
|
|
28
29
|
export const KEEP_QUIET = 5;
|
|
29
30
|
/** How many quiet children stay in an expanded family. */
|
|
30
31
|
export const KEEP_QUIET_CHILDREN = 3;
|
|
31
32
|
|
|
32
|
-
export type ListFilter = "all" | "attention";
|
|
33
|
-
|
|
34
33
|
export interface Chip {
|
|
35
34
|
count: number;
|
|
36
35
|
flag: Flag | null;
|
|
37
|
-
/**
|
|
36
|
+
/** The user opened the chip, so every child shows. */
|
|
38
37
|
expanded: boolean;
|
|
39
38
|
/** Harnesses among the visible children that differ from the parent's. */
|
|
40
39
|
providerIds: string[];
|
|
@@ -44,9 +43,9 @@ export interface ThreadRow {
|
|
|
44
43
|
type: "thread";
|
|
45
44
|
key: string;
|
|
46
45
|
info: ThreadInfo;
|
|
47
|
-
/** Visual depth: 0 for roots
|
|
46
|
+
/** Visual depth: 0 for roots, one more per level below. */
|
|
48
47
|
depth: number;
|
|
49
|
-
/** Grandchild or deeper
|
|
48
|
+
/** Grandchild or deeper: draws `↳`. */
|
|
50
49
|
nested: boolean;
|
|
51
50
|
/** Title of the thread this one attaches to, for tooltips and labels. */
|
|
52
51
|
parentTitle: string | null;
|
|
@@ -57,8 +56,8 @@ export interface ThreadRow {
|
|
|
57
56
|
hiddenBadge: boolean;
|
|
58
57
|
/** "In project X" when the thread is outside its family's group. */
|
|
59
58
|
crossGroupLabel: string | null;
|
|
60
|
-
/**
|
|
61
|
-
|
|
59
|
+
/** A root in Needs you: its home group's name, drawn where the age goes. */
|
|
60
|
+
homeGroupLabel: string | null;
|
|
62
61
|
projectId: string;
|
|
63
62
|
}
|
|
64
63
|
|
|
@@ -90,19 +89,18 @@ export interface EnvironmentRow {
|
|
|
90
89
|
rails: (Rail | null)[];
|
|
91
90
|
}
|
|
92
91
|
|
|
93
|
-
/** Under
|
|
94
|
-
export interface
|
|
95
|
-
type: "
|
|
92
|
+
/** Under a family in Needs you: how many of its child threads it leaves out ("+N more"). Not a control. */
|
|
93
|
+
export interface LeftOutRow {
|
|
94
|
+
type: "left-out";
|
|
96
95
|
key: string;
|
|
97
|
-
/** The
|
|
96
|
+
/** The family's root. */
|
|
98
97
|
scopeId: string;
|
|
98
|
+
count: number;
|
|
99
99
|
depth: number;
|
|
100
100
|
rails: (Rail | null)[];
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
export
|
|
104
|
-
|
|
105
|
-
export type Row = ThreadRow | OlderRow | EnvironmentRow | EmptyRow;
|
|
103
|
+
export type Row = ThreadRow | OlderRow | EnvironmentRow | LeftOutRow;
|
|
106
104
|
|
|
107
105
|
export interface GroupView {
|
|
108
106
|
descriptor: GroupDescriptor;
|
|
@@ -113,11 +111,25 @@ export interface GroupView {
|
|
|
113
111
|
collapsed: boolean;
|
|
114
112
|
hidden: boolean;
|
|
115
113
|
rows: Row[];
|
|
116
|
-
/** Every family root bucketed in the group,
|
|
114
|
+
/** Every family root bucketed in the group, those in Needs you and behind folds included. */
|
|
117
115
|
rootIds: string[];
|
|
118
116
|
}
|
|
119
117
|
|
|
118
|
+
/** The id Needs you's rows are drawn under; no group has it. */
|
|
119
|
+
export const NEEDS_YOU_GROUP_ID = "needs-you";
|
|
120
|
+
|
|
121
|
+
/** The Needs you section: every family with a thread that needs you, or held there while one of its threads is open. */
|
|
122
|
+
export interface NeedsYouView {
|
|
123
|
+
/** How many families the section holds, for its header. */
|
|
124
|
+
familyCount: number;
|
|
125
|
+
rows: Row[];
|
|
126
|
+
/** The home group of each thread drawn here, by thread id: a drop on its row acts there. */
|
|
127
|
+
homeGroupIds: Record<string, string>;
|
|
128
|
+
}
|
|
129
|
+
|
|
120
130
|
export interface ListView {
|
|
131
|
+
/** Null when no family is in the section. */
|
|
132
|
+
needsYou: NeedsYouView | null;
|
|
121
133
|
groups: GroupView[];
|
|
122
134
|
/** Hidden groups, shown in the More popover. */
|
|
123
135
|
more: GroupView[];
|
|
@@ -126,8 +138,6 @@ export interface ListView {
|
|
|
126
138
|
order: string[];
|
|
127
139
|
/** Threads span more than one host (the row's second line). */
|
|
128
140
|
multiHost: boolean;
|
|
129
|
-
/** Threads Needs attention would show for their own flags (the filter's badge). */
|
|
130
|
-
attentionCount: number;
|
|
131
141
|
}
|
|
132
142
|
|
|
133
143
|
export interface ViewInputs {
|
|
@@ -136,15 +146,15 @@ export interface ViewInputs {
|
|
|
136
146
|
projects: readonly PluginSidebarProject[];
|
|
137
147
|
sections: readonly PluginSidebarSection[];
|
|
138
148
|
prefs: Preferences;
|
|
139
|
-
filter: ListFilter;
|
|
140
149
|
activeThreadId: string | null;
|
|
150
|
+
/** The root of the family Needs you keeps while one of its threads is open. */
|
|
151
|
+
heldRootId: string | null;
|
|
141
152
|
targets: Targets;
|
|
142
153
|
}
|
|
143
154
|
|
|
144
155
|
interface Context extends ViewInputs {
|
|
145
156
|
compare: (a: SortKey, b: SortKey) => number;
|
|
146
157
|
expandedChildren: ReadonlySet<string>;
|
|
147
|
-
collapsedChildren: ReadonlySet<string>;
|
|
148
158
|
expandedOlder: ReadonlySet<string>;
|
|
149
159
|
collapsedEnvironments: ReadonlySet<string>;
|
|
150
160
|
/** Parent ids on the path to a reveal target. */
|
|
@@ -154,7 +164,6 @@ interface Context extends ViewInputs {
|
|
|
154
164
|
activePath: ReadonlySet<string>;
|
|
155
165
|
projectNames: ReadonlyMap<string, string>;
|
|
156
166
|
sectionNames: ReadonlyMap<string, string>;
|
|
157
|
-
subtreeAttention: Map<string, number>;
|
|
158
167
|
}
|
|
159
168
|
|
|
160
169
|
function titleOf(context: Context, id: string | null): string | null {
|
|
@@ -185,7 +194,7 @@ function threadRow(
|
|
|
185
194
|
context: Context,
|
|
186
195
|
info: ThreadInfo,
|
|
187
196
|
root: ThreadInfo,
|
|
188
|
-
options: { depth: number; nested: boolean; chip: Chip | null
|
|
197
|
+
options: { depth: number; nested: boolean; chip: Chip | null },
|
|
189
198
|
): ThreadRow {
|
|
190
199
|
return {
|
|
191
200
|
type: "thread",
|
|
@@ -198,20 +207,16 @@ function threadRow(
|
|
|
198
207
|
rails: [],
|
|
199
208
|
hiddenBadge: info.thread.isHidden,
|
|
200
209
|
crossGroupLabel: crossGroupLabel(context, info, root),
|
|
201
|
-
|
|
210
|
+
homeGroupLabel: null,
|
|
202
211
|
projectId: info.thread.projectId,
|
|
203
212
|
};
|
|
204
213
|
}
|
|
205
214
|
|
|
206
|
-
function matchesAttention(info: ThreadInfo): boolean {
|
|
207
|
-
return info.attention.size > 0;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
215
|
/** The children of `parent` that get a row: hidden ones only when they need you or failed. */
|
|
211
216
|
function eligibleChildren(context: Context, parentId: string): ThreadInfo[] {
|
|
212
217
|
return (context.forest.children.get(parentId) ?? [])
|
|
213
218
|
.map((id) => context.forest.infos.get(id)!)
|
|
214
|
-
.filter((info) => !info.thread.isHidden || info.
|
|
219
|
+
.filter((info) => !info.thread.isHidden || info.needsYou.size > 0);
|
|
215
220
|
}
|
|
216
221
|
|
|
217
222
|
function subtreeOf(context: Context, id: string): Subtree {
|
|
@@ -219,7 +224,7 @@ function subtreeOf(context: Context, id: string): Subtree {
|
|
|
219
224
|
}
|
|
220
225
|
|
|
221
226
|
/**
|
|
222
|
-
* The children of one parent that show
|
|
227
|
+
* The children of one parent that show under its chip. Each level
|
|
223
228
|
* folds on its own: its direct children only, and a child's own children wait
|
|
224
229
|
* for that child's chip. A child never shows without its parent, because the
|
|
225
230
|
* parent's level is the only place it is drawn.
|
|
@@ -231,13 +236,6 @@ function foldedChildren(
|
|
|
231
236
|
): { shown: ThreadInfo[]; older: OlderRow | null } {
|
|
232
237
|
const parentId = parent.thread.id;
|
|
233
238
|
const kids = eligibleChildren(context, parentId);
|
|
234
|
-
const holds = (kid: ThreadInfo, test: (info: ThreadInfo) => boolean) =>
|
|
235
|
-
test(kid) || subtreeOf(context, kid.thread.id).descendants.some(test);
|
|
236
|
-
|
|
237
|
-
if (context.filter === "attention") {
|
|
238
|
-
const shown = kids.filter((kid) => holds(kid, (info) => matchesAttention(info) || info.isActive));
|
|
239
|
-
return { shown, older: null };
|
|
240
|
-
}
|
|
241
239
|
|
|
242
240
|
const reveals = (kid: ThreadInfo) => context.revealIds.has(kid.thread.id) || context.revealPath.has(kid.thread.id);
|
|
243
241
|
const olderRow = (scope: "family" | "reveal", count: number, expanded: boolean): OlderRow | null =>
|
|
@@ -261,10 +259,10 @@ function foldedChildren(
|
|
|
261
259
|
return { shown, older: shown.length > 0 ? olderRow("reveal", left, false) : null };
|
|
262
260
|
}
|
|
263
261
|
|
|
264
|
-
// The stable set reads `
|
|
262
|
+
// The stable set reads `quietIgnoringOpen` for the child and everything under it, and
|
|
265
263
|
// ignores which thread is open and the reveal targets: they join afterwards
|
|
266
264
|
// and take no other row's place.
|
|
267
|
-
const quietTree = (kid: ThreadInfo) => kid.
|
|
265
|
+
const quietTree = (kid: ThreadInfo) => kid.quietIgnoringOpen && subtreeOf(context, kid.thread.id).quietIgnoringOpen;
|
|
268
266
|
const stable = new Set<string>();
|
|
269
267
|
for (const kid of kids) if (kid.thread.isHidden || !quietTree(kid)) stable.add(kid.thread.id);
|
|
270
268
|
const recentQuiet = kids
|
|
@@ -281,18 +279,6 @@ function foldedChildren(
|
|
|
281
279
|
return { shown, older: olderRow("family", folded, olderExpanded) };
|
|
282
280
|
}
|
|
283
281
|
|
|
284
|
-
function subtreeAttention(context: Context, id: string): number {
|
|
285
|
-
const cached = context.subtreeAttention.get(id);
|
|
286
|
-
if (cached !== undefined) return cached;
|
|
287
|
-
context.subtreeAttention.set(id, context.forest.infos.get(id)?.thread.latestAttentionAt ?? 0);
|
|
288
|
-
let max = context.forest.infos.get(id)?.thread.latestAttentionAt ?? 0;
|
|
289
|
-
for (const child of context.forest.children.get(id) ?? []) {
|
|
290
|
-
max = Math.max(max, subtreeAttention(context, child));
|
|
291
|
-
}
|
|
292
|
-
context.subtreeAttention.set(id, max);
|
|
293
|
-
return max;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
282
|
function environmentLabel(thread: PluginSidebarThread): string {
|
|
297
283
|
const environment = thread.environment;
|
|
298
284
|
if (environment === null) return "Environment";
|
|
@@ -371,7 +357,7 @@ function childProviders(parent: ThreadInfo, descendants: readonly ThreadInfo[]):
|
|
|
371
357
|
function chipOf(context: Context, info: ThreadInfo, expanded: boolean): Chip | null {
|
|
372
358
|
const subtree = subtreeOf(context, info.thread.id);
|
|
373
359
|
const hasChildren =
|
|
374
|
-
subtree.visibleCount > 0 || subtree.descendants.some((descendant) => descendant.thread.isHidden && descendant.
|
|
360
|
+
subtree.visibleCount > 0 || subtree.descendants.some((descendant) => descendant.thread.isHidden && descendant.needsYou.size > 0);
|
|
375
361
|
if (!hasChildren) return null;
|
|
376
362
|
return {
|
|
377
363
|
count: subtree.visibleCount,
|
|
@@ -383,12 +369,12 @@ function chipOf(context: Context, info: ThreadInfo, expanded: boolean): Chip | n
|
|
|
383
369
|
|
|
384
370
|
/** What a thread and everything under it carry, for an environment folder's glyph. */
|
|
385
371
|
function rollupFlags(context: Context, info: ThreadInfo): Set<Flag> {
|
|
386
|
-
const flags = new Set<Flag>([...info.
|
|
372
|
+
const flags = new Set<Flag>([...info.needsYou, ...subtreeOf(context, info.thread.id).flags]);
|
|
387
373
|
if (info.flags.has("working")) flags.add("working");
|
|
388
374
|
return flags;
|
|
389
375
|
}
|
|
390
376
|
|
|
391
|
-
/** The rows under `parent
|
|
377
|
+
/** The rows under `parent`: each shown child, then its own level. */
|
|
392
378
|
function foldedLevel(context: Context, family: Family, parent: ThreadInfo, depth: number): Row[] {
|
|
393
379
|
const { shown, older } = foldedChildren(context, parent, depth);
|
|
394
380
|
const units: Unit[] = shown.map((info) => ({
|
|
@@ -407,10 +393,6 @@ function foldedLevel(context: Context, family: Family, parent: ThreadInfo, depth
|
|
|
407
393
|
}));
|
|
408
394
|
const rows = clusterEnvironments(context, units, depth);
|
|
409
395
|
if (older !== null) rows.push(older);
|
|
410
|
-
// An opened chip that the filter empties says so, instead of showing an open chevron over nothing.
|
|
411
|
-
if (context.filter === "attention" && shown.length === 0 && context.expandedChildren.has(parent.thread.id)) {
|
|
412
|
-
rows.push({ type: "empty", key: `empty:${parent.thread.id}`, scopeId: parent.thread.id, depth, rails: [] });
|
|
413
|
-
}
|
|
414
396
|
return rows;
|
|
415
397
|
}
|
|
416
398
|
|
|
@@ -420,72 +402,98 @@ function foldedFamilyRows(context: Context, family: Family): Row[] {
|
|
|
420
402
|
return [threadRow(context, root, root, { depth: 0, nested: false, chip }), ...foldedLevel(context, family, root, 1)];
|
|
421
403
|
}
|
|
422
404
|
|
|
423
|
-
/** Tree nesting: bb's tree, with chips on collapsed parents. */
|
|
424
|
-
function treeRows(context: Context, family: Family, info: ThreadInfo, depth: number): Unit {
|
|
425
|
-
const id = info.thread.id;
|
|
426
|
-
const root = family.root;
|
|
427
|
-
const childIds = (context.forest.children.get(id) ?? []).filter((childId) => {
|
|
428
|
-
const child = context.forest.infos.get(childId)!;
|
|
429
|
-
return !child.thread.isHidden || child.attention.size > 0;
|
|
430
|
-
});
|
|
431
|
-
const subtree = subtreeOf(context, id);
|
|
432
|
-
let children = childIds.map((childId) => context.forest.infos.get(childId)!);
|
|
433
|
-
if (context.filter === "attention") {
|
|
434
|
-
children = children.filter((child) => {
|
|
435
|
-
const test = (candidate: ThreadInfo) => matchesAttention(candidate) || candidate.isActive;
|
|
436
|
-
return test(child) || subtreeOf(context, child.thread.id).descendants.some(test);
|
|
437
|
-
});
|
|
438
|
-
}
|
|
439
|
-
const open =
|
|
440
|
-
context.filter === "attention" ||
|
|
441
|
-
!context.collapsedChildren.has(id) ||
|
|
442
|
-
context.revealPath.has(id);
|
|
443
|
-
const chip: Chip | null =
|
|
444
|
-
childIds.length > 0
|
|
445
|
-
? // Drawn closed when the filter leaves nothing under it.
|
|
446
|
-
{ count: subtree.visibleCount, flag: mostUrgent(subtree.flags), expanded: open && children.length > 0, providerIds: childProviders(info, subtree.descendants) }
|
|
447
|
-
: null;
|
|
448
|
-
const ownRow = threadRow(context, info, root, {
|
|
449
|
-
depth,
|
|
450
|
-
nested: false,
|
|
451
|
-
chip,
|
|
452
|
-
stickyLevel: open && children.length > 0 && depth < 4 ? depth : null,
|
|
453
|
-
});
|
|
454
|
-
const ownFlags = rollupFlags(context, info);
|
|
455
|
-
if (!open || children.length === 0) return { info, rows: [ownRow], flags: ownFlags };
|
|
456
|
-
const keyed = children.map((child) => ({
|
|
457
|
-
child,
|
|
458
|
-
key: { thread: child.thread, familyAttention: subtreeAttention(context, child.thread.id) },
|
|
459
|
-
}));
|
|
460
|
-
keyed.sort((a, b) => context.compare(a.key, b.key));
|
|
461
|
-
const units = keyed.map(({ child }) => treeRows(context, family, child, depth + 1));
|
|
462
|
-
return { info, rows: [ownRow, ...clusterEnvironments(context, units, depth + 1)], flags: ownFlags };
|
|
463
|
-
}
|
|
464
|
-
|
|
465
405
|
function familyUnit(context: Context, family: Family): Unit {
|
|
466
|
-
if (context.prefs.nesting === "tree") return treeRows(context, family, family.root, 0);
|
|
467
406
|
return { info: family.root, rows: foldedFamilyRows(context, family), flags: family.flags };
|
|
468
407
|
}
|
|
469
408
|
|
|
470
|
-
/**
|
|
409
|
+
/** Waiting on you, then failed, then offline, then unread, then the rest. */
|
|
471
410
|
export function urgency(family: Family): number {
|
|
472
|
-
const flags = family.
|
|
473
|
-
if (flags.has("
|
|
411
|
+
const flags = family.needsYouFlags;
|
|
412
|
+
if (flags.has("waits-on-you")) return 0;
|
|
474
413
|
if (flags.has("unread-failed") || flags.has("queue-failed")) return 1;
|
|
475
414
|
if (flags.has("offline")) return 2;
|
|
476
415
|
if (flags.has("unread")) return 3;
|
|
477
416
|
return 4;
|
|
478
417
|
}
|
|
479
418
|
|
|
419
|
+
/**
|
|
420
|
+
* One family's rows in Needs you: the path from the root down to each thread
|
|
421
|
+
* that needs you or is open, then one "+N more" line for the child threads
|
|
422
|
+
* left out. No row draws a chip, since the section never opens one.
|
|
423
|
+
*/
|
|
424
|
+
function needsYouFamilyRows(context: Context, family: Family, homeGroupLabel: string): Row[] {
|
|
425
|
+
const root = family.root;
|
|
426
|
+
const onPath = new Set<string>([root.thread.id]);
|
|
427
|
+
for (const info of family.descendants) {
|
|
428
|
+
const needsYou = !info.thread.isArchived && info.needsYou.size > 0;
|
|
429
|
+
if (!needsYou && !info.isActive) continue;
|
|
430
|
+
onPath.add(info.thread.id);
|
|
431
|
+
for (const ancestor of ancestorsOf(info.thread.id, context.forest.infos, root.thread.id)) onPath.add(ancestor);
|
|
432
|
+
}
|
|
433
|
+
const rows: Row[] = [{ ...threadRow(context, root, root, { depth: 0, nested: false, chip: null }), homeGroupLabel }];
|
|
434
|
+
const walk = (parentId: string, depth: number) => {
|
|
435
|
+
for (const id of context.forest.children.get(parentId) ?? []) {
|
|
436
|
+
if (!onPath.has(id)) continue;
|
|
437
|
+
rows.push(threadRow(context, context.forest.infos.get(id)!, root, { depth, nested: depth > 1, chip: null }));
|
|
438
|
+
walk(id, depth + 1);
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
walk(root.thread.id, 1);
|
|
442
|
+
const left = family.descendants.filter((info) => !info.thread.isHidden && !onPath.has(info.thread.id)).length;
|
|
443
|
+
if (left > 0) rows.push({ type: "left-out", key: `left-out:${root.thread.id}`, scopeId: root.thread.id, count: left, depth: 1, rails: [] });
|
|
444
|
+
return rows;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function buildNeedsYou(
|
|
448
|
+
context: Context,
|
|
449
|
+
families: readonly Family[],
|
|
450
|
+
homeGroupOf: (family: Family) => GroupDescriptor,
|
|
451
|
+
): NeedsYouView | null {
|
|
452
|
+
if (families.length === 0) return null;
|
|
453
|
+
// Most urgent first, then the chosen field in its natural direction: the
|
|
454
|
+
// sort direction orders the groups only.
|
|
455
|
+
const compare = makeComparator({
|
|
456
|
+
field: context.prefs.chronologicalSort,
|
|
457
|
+
direction: "default",
|
|
458
|
+
workingFirst: context.prefs.workingFirst,
|
|
459
|
+
});
|
|
460
|
+
const sorted = [...families].sort(
|
|
461
|
+
(a, b) =>
|
|
462
|
+
urgency(a) - urgency(b) ||
|
|
463
|
+
compare(
|
|
464
|
+
{ thread: a.root.thread, familyAttention: a.latestAttentionAt },
|
|
465
|
+
{ thread: b.root.thread, familyAttention: b.latestAttentionAt },
|
|
466
|
+
),
|
|
467
|
+
);
|
|
468
|
+
const homeGroupIds: Record<string, string> = {};
|
|
469
|
+
const rows = sorted.flatMap((family) => {
|
|
470
|
+
const home = homeGroupOf(family);
|
|
471
|
+
const familyRows = needsYouFamilyRows(context, family, home.label);
|
|
472
|
+
for (const row of familyRows) if (row.type === "thread") homeGroupIds[row.info.thread.id] = home.id;
|
|
473
|
+
return familyRows;
|
|
474
|
+
});
|
|
475
|
+
const rails = railsFor(rows.map((row) => row.depth));
|
|
476
|
+
return {
|
|
477
|
+
familyCount: sorted.length,
|
|
478
|
+
rows: rows.map((row, index) => ({ ...row, rails: rails[index]! })),
|
|
479
|
+
homeGroupIds,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* One group. `families` is every family bucketed in it, for its counters and
|
|
485
|
+
* `rootIds`; `drawn` leaves out those in Needs you.
|
|
486
|
+
*/
|
|
480
487
|
function buildGroup(
|
|
481
488
|
context: Context,
|
|
482
489
|
descriptor: GroupDescriptor,
|
|
483
490
|
families: Family[],
|
|
491
|
+
drawn: Family[],
|
|
484
492
|
hidden: boolean,
|
|
485
|
-
): GroupView
|
|
493
|
+
): GroupView {
|
|
486
494
|
const counters = countFamilies(families);
|
|
487
495
|
const userCollapsed = isGroupCollapsed(descriptor, context.prefs);
|
|
488
|
-
const hasTarget =
|
|
496
|
+
const hasTarget = drawn.some(
|
|
489
497
|
(family) =>
|
|
490
498
|
context.targets.has(family.root.thread.id) ||
|
|
491
499
|
family.descendants.some((info) => context.targets.has(info.thread.id)),
|
|
@@ -493,42 +501,30 @@ function buildGroup(
|
|
|
493
501
|
const collapsed = userCollapsed && !hasTarget;
|
|
494
502
|
const isPinned = descriptor.id === PINNED_GROUP_ID;
|
|
495
503
|
|
|
496
|
-
|
|
497
|
-
if (
|
|
498
|
-
kept = families.filter((family) => family.attentionFlags.size > 0 || family.containsActive);
|
|
499
|
-
if (kept.length === 0) return null;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
const sorted = [...kept];
|
|
503
|
-
if (context.filter === "attention") {
|
|
504
|
-
// Needs attention reads most urgent first.
|
|
505
|
-
sorted.sort(
|
|
506
|
-
(a, b) =>
|
|
507
|
-
urgency(a) - urgency(b) ||
|
|
508
|
-
context.compare(
|
|
509
|
-
{ thread: a.root.thread, familyAttention: a.attention },
|
|
510
|
-
{ thread: b.root.thread, familyAttention: b.attention },
|
|
511
|
-
),
|
|
512
|
-
);
|
|
513
|
-
} else if (isPinned) {
|
|
504
|
+
const sorted = [...drawn];
|
|
505
|
+
if (isPinned) {
|
|
514
506
|
sorted.sort((a, b) => comparePinned(a.root.thread, b.root.thread));
|
|
515
507
|
} else {
|
|
516
508
|
sorted.sort((a, b) =>
|
|
517
509
|
context.compare(
|
|
518
|
-
{ thread: a.root.thread, familyAttention: a.
|
|
519
|
-
{ thread: b.root.thread, familyAttention: b.
|
|
510
|
+
{ thread: a.root.thread, familyAttention: a.latestAttentionAt },
|
|
511
|
+
{ thread: b.root.thread, familyAttention: b.latestAttentionAt },
|
|
520
512
|
),
|
|
521
513
|
);
|
|
522
514
|
}
|
|
523
515
|
|
|
524
516
|
let visible = sorted;
|
|
525
517
|
let older: OlderRow | null = null;
|
|
526
|
-
const foldable = !isPinned && context.
|
|
518
|
+
const foldable = !isPinned && context.prefs.foldOlder;
|
|
527
519
|
if (foldable) {
|
|
528
|
-
// The fold reads `
|
|
520
|
+
// The fold reads `quietIgnoringOpen`, as if no thread were open. The open family
|
|
529
521
|
// joins afterwards when it sits behind the fold, and takes no other row's place.
|
|
530
|
-
const quietActive = sorted.filter((family) => family.
|
|
531
|
-
|
|
522
|
+
const quietActive = sorted.filter((family) => family.quietIgnoringOpen && !family.root.thread.isArchived);
|
|
523
|
+
// The newest quiet roots stay whatever the order: by creation under
|
|
524
|
+
// Created, by latest activity otherwise.
|
|
525
|
+
const byCreation = effectiveSortField(context.prefs.chronologicalSort) === "created";
|
|
526
|
+
const age = (family: Family) => (byCreation ? family.root.thread.createdAt : family.latestAttentionAt);
|
|
527
|
+
const keepQuiet = new Set([...quietActive].sort((a, b) => age(b) - age(a)).slice(0, KEEP_QUIET));
|
|
532
528
|
const foldedFamilies = quietActive.filter((family) => !keepQuiet.has(family) && !family.containsActive);
|
|
533
529
|
if (foldedFamilies.length > 0) {
|
|
534
530
|
const opened =
|
|
@@ -591,7 +587,6 @@ export function buildListView(inputs: ViewInputs): ListView {
|
|
|
591
587
|
workingFirst: prefs.workingFirst,
|
|
592
588
|
}),
|
|
593
589
|
expandedChildren: new Set(prefs.expandedChildren),
|
|
594
|
-
collapsedChildren: new Set(prefs.collapsedChildren),
|
|
595
590
|
expandedOlder: new Set(prefs.expandedOlder),
|
|
596
591
|
collapsedEnvironments: new Set(prefs.collapsedEnvironments),
|
|
597
592
|
revealIds,
|
|
@@ -599,10 +594,12 @@ export function buildListView(inputs: ViewInputs): ListView {
|
|
|
599
594
|
activePath,
|
|
600
595
|
projectNames: new Map(inputs.projects.map((project) => [project.id, project.name])),
|
|
601
596
|
sectionNames: new Map(inputs.sections.map((section) => [section.id, section.name])),
|
|
602
|
-
subtreeAttention: new Map(),
|
|
603
597
|
};
|
|
604
598
|
|
|
605
599
|
const byGroup = new Map<string, Family[]>();
|
|
600
|
+
const homeOf = new Map<Family, string>();
|
|
601
|
+
const section: Family[] = [];
|
|
602
|
+
const openRootId = inputs.activeThreadId === null ? null : (forest.familyOf.get(inputs.activeThreadId)?.root.thread.id ?? null);
|
|
606
603
|
for (const family of forest.families) {
|
|
607
604
|
const id = groupIdForRoot(family.root.thread, {
|
|
608
605
|
mode: prefs.organizationMode,
|
|
@@ -611,7 +608,10 @@ export function buildListView(inputs: ViewInputs): ListView {
|
|
|
611
608
|
const list = byGroup.get(id) ?? [];
|
|
612
609
|
list.push(family);
|
|
613
610
|
byGroup.set(id, list);
|
|
611
|
+
homeOf.set(family, id);
|
|
612
|
+
if (inNeedsYou(family, inputs.heldRootId, openRootId)) section.push(family);
|
|
614
613
|
}
|
|
614
|
+
const inSection = new Set(section);
|
|
615
615
|
|
|
616
616
|
const alphabetical = effectiveSortField(prefs.chronologicalSort) === "alpha";
|
|
617
617
|
const entities = entityGroups(
|
|
@@ -635,15 +635,15 @@ export function buildListView(inputs: ViewInputs): ListView {
|
|
|
635
635
|
const descriptor = descriptors.get(id);
|
|
636
636
|
if (descriptor === undefined) continue;
|
|
637
637
|
const families = byGroup.get(id) ?? [];
|
|
638
|
-
|
|
639
|
-
//
|
|
640
|
-
|
|
638
|
+
const drawn = families.filter((family) => !inSection.has(family));
|
|
639
|
+
// Pinned and the loose bucket appear only when they hold threads outside
|
|
640
|
+
// Needs you, except the loose bucket in custom-section mode, as in bb.
|
|
641
|
+
if (drawn.length === 0) {
|
|
641
642
|
if (id === PINNED_GROUP_ID) continue;
|
|
642
643
|
if (id === THREADS_GROUP_ID && prefs.organizationMode !== "chronological") continue;
|
|
643
644
|
}
|
|
644
645
|
const hidden = id !== PINNED_GROUP_ID && hiddenIds.has(id);
|
|
645
|
-
const view = buildGroup(context, descriptor, families, hidden);
|
|
646
|
-
if (view === null) continue;
|
|
646
|
+
const view = buildGroup(context, descriptor, families, drawn, hidden);
|
|
647
647
|
if (hidden) {
|
|
648
648
|
more.push(view);
|
|
649
649
|
moreCounters = addCounters(moreCounters, view.counters);
|
|
@@ -654,13 +654,9 @@ export function buildListView(inputs: ViewInputs): ListView {
|
|
|
654
654
|
|
|
655
655
|
const hosts = new Set<string>();
|
|
656
656
|
for (const thread of inputs.threads) if (thread.host !== null) hosts.add(thread.host.id);
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
if (!info.thread.isArchived && matchesAttention(info)) attentionCount += 1;
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
return { groups, more, moreCounters, order, multiHost: hosts.size > 1, attentionCount };
|
|
657
|
+
// Every root's group id has a descriptor: entity groups include unknown sections and every host.
|
|
658
|
+
const needsYou = buildNeedsYou(context, section, (family) => descriptors.get(homeOf.get(family)!)!);
|
|
659
|
+
return { needsYou, groups, more, moreCounters, order, multiHost: hosts.size > 1 };
|
|
664
660
|
}
|
|
665
661
|
|
|
666
662
|
/** Every thread row in visual order, for keyboard and windowing. */
|
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
} from "@get-bb/plugin-sdk/app";
|
|
7
7
|
import { defaultPreferences, type Preferences } from "@/shared/preferences";
|
|
8
8
|
import { buildForest, type Forest } from "../model/families";
|
|
9
|
-
import { buildListView, type
|
|
9
|
+
import { buildListView, type ListView, type Row } from "../model/view";
|
|
10
10
|
import type { Targets } from "../model/expansion";
|
|
11
11
|
|
|
12
12
|
export const T0 = 1_780_000_000_000;
|
|
@@ -107,8 +107,8 @@ export interface Scenario {
|
|
|
107
107
|
projects?: PluginSidebarProject[];
|
|
108
108
|
sections?: PluginSidebarSection[];
|
|
109
109
|
prefs?: Partial<Preferences>;
|
|
110
|
-
filter?: ListFilter;
|
|
111
110
|
activeThreadId?: string | null;
|
|
111
|
+
heldRootId?: string | null;
|
|
112
112
|
targets?: Targets;
|
|
113
113
|
finishedAt?: Record<string, number>;
|
|
114
114
|
seenAt?: Record<string, number>;
|
|
@@ -140,23 +140,32 @@ export function viewOf(scenario: Scenario): ListView {
|
|
|
140
140
|
projects: scenario.projects ?? PROJECTS,
|
|
141
141
|
sections: scenario.sections ?? [],
|
|
142
142
|
prefs: { ...defaultPreferences(), ...scenario.prefs },
|
|
143
|
-
filter: scenario.filter ?? "all",
|
|
144
143
|
activeThreadId: scenario.activeThreadId ?? null,
|
|
144
|
+
heldRootId: scenario.heldRootId ?? null,
|
|
145
145
|
targets: scenario.targets ?? new Map(),
|
|
146
146
|
});
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const group = [...view.groups, ...view.more].find((candidate) => candidate.descriptor.id === groupId);
|
|
152
|
-
if (group === undefined) throw new Error(`no group ${groupId}`);
|
|
153
|
-
return group.rows.map((row) =>
|
|
149
|
+
function idsOf(rows: readonly Row[]): string[] {
|
|
150
|
+
return rows.map((row) =>
|
|
154
151
|
row.type === "thread"
|
|
155
152
|
? row.info.thread.id
|
|
156
153
|
: row.type === "older"
|
|
157
154
|
? `older:${row.count}`
|
|
158
|
-
: row.type === "
|
|
159
|
-
?
|
|
155
|
+
: row.type === "left-out"
|
|
156
|
+
? `+${row.count}`
|
|
160
157
|
: `env:${row.environmentId}`,
|
|
161
158
|
);
|
|
162
159
|
}
|
|
160
|
+
|
|
161
|
+
/** Thread ids in the group's rows, in order; older rows as `older:N`. */
|
|
162
|
+
export function rowIds(view: ListView, groupId: string): string[] {
|
|
163
|
+
const group = [...view.groups, ...view.more].find((candidate) => candidate.descriptor.id === groupId);
|
|
164
|
+
if (group === undefined) throw new Error(`no group ${groupId}`);
|
|
165
|
+
return idsOf(group.rows);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Thread ids in Needs you, in order; "+N more" lines as `+N`. Empty when the section is absent. */
|
|
169
|
+
export function needsYouIds(view: ListView): string[] {
|
|
170
|
+
return idsOf(view.needsYou?.rows ?? []);
|
|
171
|
+
}
|
package/package.json
CHANGED
package/server/cli.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from "../shared/preferences";
|
|
10
10
|
import { PreferenceValidationError, type PreferenceStore } from "./preference-store";
|
|
11
11
|
|
|
12
|
-
/** Parses a CLI value as JSON; a bare word such as `
|
|
12
|
+
/** Parses a CLI value as JSON; a bare word such as `machine` is read as a string. */
|
|
13
13
|
export function parseCliValue(raw: string): unknown {
|
|
14
14
|
try {
|
|
15
15
|
return JSON.parse(raw) as unknown;
|
|
@@ -38,7 +38,7 @@ export function createCli(store: PreferenceStore): PluginCliRegistration {
|
|
|
38
38
|
name: "thread-glance",
|
|
39
39
|
summary: "Inspect and change the Thread Glance sidebar's layout preferences",
|
|
40
40
|
description:
|
|
41
|
-
"Organization, sort, group order, hidden and collapsed groups, child
|
|
41
|
+
"Organization, sort, group order, hidden and collapsed groups, child-thread folding and which child threads Needs you counts, for the Thread Glance sidebar. Values are JSON; a bare word is read as a string.",
|
|
42
42
|
commands: {
|
|
43
43
|
"prefs list": cliCommand({
|
|
44
44
|
summary: "List every preference and its current value",
|