@gperezmz/bb-plugin-thread-glance 0.2.2 → 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/dist/app.js +7 -7
- package/dist/app.meta.json +1 -1
- package/dist/server.js.map +1 -1
- package/dist/server.meta.json +1 -1
- package/features/thread-list/components/GroupSection.tsx +11 -9
- package/features/thread-list/components/ThreadList.tsx +3 -3
- package/features/thread-list/components/ThreadRowView.tsx +1 -1
- package/features/thread-list/data/useAttentionHold.ts +7 -7
- package/features/thread-list/model/attention.ts +29 -9
- package/features/thread-list/model/view.ts +34 -15
- package/features/thread-list/testing/fixtures.ts +5 -1
- package/package.json +1 -1
package/dist/server.meta.json
CHANGED
|
@@ -423,9 +423,9 @@ const ATTENTION_COUNT =
|
|
|
423
423
|
"bg-[color-mix(in_oklab,var(--attention)_20%,transparent)] text-[color:color-mix(in_oklab,var(--attention),var(--foreground)_55%)]";
|
|
424
424
|
|
|
425
425
|
/**
|
|
426
|
-
* The Needs attention section: a header with its family count
|
|
427
|
-
* on one band of bb's attention
|
|
428
|
-
* no drop target of its own.
|
|
426
|
+
* The Needs attention section: a header with its family count (none when every
|
|
427
|
+
* family in it is attended), then its rows, on one band of bb's attention
|
|
428
|
+
* colour. It has no collapse, menu or drag, and no drop target of its own.
|
|
429
429
|
*/
|
|
430
430
|
export const AttentionSection = memo(function AttentionSection({ view, ...rest }: SectionProps & { view: AttentionView }) {
|
|
431
431
|
return (
|
|
@@ -435,12 +435,14 @@ export const AttentionSection = memo(function AttentionSection({ view, ...rest }
|
|
|
435
435
|
className="sticky z-20 flex h-7 items-center gap-1 rounded-t-md bg-[var(--tg-surface)] pl-2 pr-1 text-xs font-medium text-muted-foreground max-md:pointer-coarse:h-9"
|
|
436
436
|
>
|
|
437
437
|
<span className="min-w-0 flex-1 truncate">Needs attention</span>
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
438
|
+
{view.familyCount > 0 ? (
|
|
439
|
+
<span
|
|
440
|
+
className={cn("rounded-full px-1.5 text-[11px] leading-4 tabular-nums", ATTENTION_COUNT)}
|
|
441
|
+
aria-label={`${view.familyCount} ${view.familyCount === 1 ? "family" : "families"}`}
|
|
442
|
+
>
|
|
443
|
+
{view.familyCount}
|
|
444
|
+
</span>
|
|
445
|
+
) : null}
|
|
444
446
|
</h2>
|
|
445
447
|
<Rows {...rest} rows={view.rows} groupId={ATTENTION_GROUP_ID} inPinned={false} forceMount={false} />
|
|
446
448
|
</section>
|
|
@@ -160,7 +160,7 @@ function ThreadListBody({
|
|
|
160
160
|
[ready, threads, activeThreadId, stamps.finishedAt, stamps.seenAt, draftIds, scheduled, now, notes, prefs.childAttention],
|
|
161
161
|
);
|
|
162
162
|
const { targets, prune } = useAutoExpand(hydrated ? forest : null, activeThreadId);
|
|
163
|
-
const
|
|
163
|
+
const held = useAttentionHold(forest, activeThreadId);
|
|
164
164
|
// Rows and groups that did not change keep their objects, so their
|
|
165
165
|
// memoized components skip the render.
|
|
166
166
|
const previousView = useRef<ListView | null>(null);
|
|
@@ -175,10 +175,10 @@ function ThreadListBody({
|
|
|
175
175
|
sections: sidebar.sections,
|
|
176
176
|
prefs,
|
|
177
177
|
activeThreadId,
|
|
178
|
-
|
|
178
|
+
held,
|
|
179
179
|
targets,
|
|
180
180
|
})),
|
|
181
|
-
[forest, threads, sidebar.projects, sidebar.sections, prefs, activeThreadId,
|
|
181
|
+
[forest, threads, sidebar.projects, sidebar.sections, prefs, activeThreadId, held, targets],
|
|
182
182
|
);
|
|
183
183
|
useLayoutEffect(() => {
|
|
184
184
|
previousView.current = view;
|
|
@@ -408,7 +408,7 @@ export const ThreadRowView = memo(function ThreadRowView({
|
|
|
408
408
|
title={thread.displayTitle}
|
|
409
409
|
className={cn(
|
|
410
410
|
"min-w-0 truncate",
|
|
411
|
-
|
|
411
|
+
row.bold ? "font-semibold" : "font-normal",
|
|
412
412
|
// Children sit a step below their parent.
|
|
413
413
|
row.depth > 0 && "text-xs",
|
|
414
414
|
// Quiet threads step back so live ones lead; hover brings them back.
|
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
// Which family Needs attention holds, and which family is open, while one of its
|
|
2
|
-
// render to render, as auto-expansion is; nothing here is persisted.
|
|
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
3
|
import { useRef } from "react";
|
|
4
4
|
import type { Forest } from "../model/families";
|
|
5
|
-
import { holdAttention, NO_HOLD, type AttentionHold } from "../model/attention";
|
|
5
|
+
import { holdAttention, NO_HOLD, type AttentionHold, type SectionFamily } from "../model/attention";
|
|
6
6
|
|
|
7
|
-
export function useAttentionHold(forest: Forest | null, activeThreadId: string | null):
|
|
8
|
-
const
|
|
7
|
+
export function useAttentionHold(forest: Forest | null, activeThreadId: string | null): SectionFamily | null {
|
|
8
|
+
const hold = useRef<AttentionHold>(NO_HOLD);
|
|
9
9
|
const lastForest = useRef<Forest | null>(null);
|
|
10
10
|
const lastActive = useRef<string | null | undefined>(undefined);
|
|
11
11
|
// Derived during render, so the family never leaves for a frame. Idempotent:
|
|
12
12
|
// a repeated render with the same inputs keeps what it stored.
|
|
13
13
|
if (forest !== null && (forest !== lastForest.current || activeThreadId !== lastActive.current)) {
|
|
14
|
-
|
|
14
|
+
hold.current = holdAttention(hold.current, forest, activeThreadId);
|
|
15
15
|
lastForest.current = forest;
|
|
16
16
|
lastActive.current = activeThreadId;
|
|
17
17
|
}
|
|
18
|
-
return
|
|
18
|
+
return hold.current.held;
|
|
19
19
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
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. */
|
|
@@ -90,17 +90,20 @@ export function revealsOn(attention: ReadonlySet<Flag>, isRoot: boolean): boolea
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/** What the section reads of a family. */
|
|
93
|
-
type SectionFamily = Pick<Family, "root" | "attentionFlags">;
|
|
93
|
+
export type SectionFamily = Pick<Family, "root" | "attentionFlags" | "latestAttentionAt">;
|
|
94
94
|
|
|
95
95
|
/** What Needs attention remembers between renders: the family it holds, and the family that was open. */
|
|
96
96
|
export interface AttentionHold {
|
|
97
|
-
/**
|
|
98
|
-
|
|
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: AttentionHold = {
|
|
106
|
+
export const NO_HOLD: AttentionHold = { held: null, openRootId: null };
|
|
104
107
|
|
|
105
108
|
/**
|
|
106
109
|
* Whether a family is in the Needs attention section: it is the held family, or one
|
|
@@ -114,20 +117,37 @@ export function inAttention(family: SectionFamily, heldRootId: string | null, op
|
|
|
114
117
|
return family.attentionFlags.size > 0 && rootId !== openRootId;
|
|
115
118
|
}
|
|
116
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;
|
|
126
|
+
}
|
|
127
|
+
|
|
117
128
|
/**
|
|
118
129
|
* The hold after a render. A family is judged when a thread in it is opened
|
|
119
130
|
* from outside it: it is held if something in it needs attention then, and it stays
|
|
120
131
|
* held while one of its threads is open, after nothing in it needs attention any
|
|
121
|
-
* more, until
|
|
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
135
|
* that never needed attention. Opening another thread of the open family judges
|
|
124
136
|
* nothing again.
|
|
125
137
|
*/
|
|
126
|
-
export function holdAttention(
|
|
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
|
}
|
|
@@ -20,7 +20,7 @@ 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 { inAttention } from "./attention";
|
|
23
|
+
import { inAttention, isAttended, type SectionFamily } from "./attention";
|
|
24
24
|
import { mostUrgent, type Flag } from "./state";
|
|
25
25
|
import type { Targets } from "./expansion";
|
|
26
26
|
import type { RowNote } from "./notes";
|
|
@@ -50,9 +50,12 @@ export interface ThreadRow {
|
|
|
50
50
|
/** Title of the thread this one attaches to, for tooltips and labels. */
|
|
51
51
|
parentTitle: string | null;
|
|
52
52
|
chip: Chip | null;
|
|
53
|
+
/** The title is bold: the thread is unread and its family is not attended. */
|
|
54
|
+
bold: boolean;
|
|
53
55
|
/**
|
|
54
56
|
* The line under the title. In Needs attention, a row that itself needs
|
|
55
|
-
* attention says why
|
|
57
|
+
* attention says why, and an attended family's rows have none; any other
|
|
58
|
+
* row says why it waits on you or failed.
|
|
56
59
|
*/
|
|
57
60
|
note: RowNote | null;
|
|
58
61
|
/** The title, and the chip with it, step back: see `isDimmed`. */
|
|
@@ -122,7 +125,7 @@ export const ATTENTION_GROUP_ID = "attention";
|
|
|
122
125
|
|
|
123
126
|
/** The Needs attention section: every family with a thread that needs attention, or held there while one of its threads is open. */
|
|
124
127
|
export interface AttentionView {
|
|
125
|
-
/** How many families the section holds, for its header. */
|
|
128
|
+
/** How many families the section holds, attended ones left out, for its header. */
|
|
126
129
|
familyCount: number;
|
|
127
130
|
rows: Row[];
|
|
128
131
|
/** The home group of each thread drawn here, by thread id: a drop on its row acts there. */
|
|
@@ -149,12 +152,13 @@ export interface ViewInputs {
|
|
|
149
152
|
sections: readonly PluginSidebarSection[];
|
|
150
153
|
prefs: Preferences;
|
|
151
154
|
activeThreadId: string | null;
|
|
152
|
-
/** The
|
|
153
|
-
|
|
155
|
+
/** The family Needs attention keeps while one of its threads is open, as it was when opened: the section orders it by this. */
|
|
156
|
+
held: SectionFamily | null;
|
|
154
157
|
targets: Targets;
|
|
155
158
|
}
|
|
156
159
|
|
|
157
160
|
interface Context extends ViewInputs {
|
|
161
|
+
heldRootId: string | null;
|
|
158
162
|
compare: (a: SortKey, b: SortKey) => number;
|
|
159
163
|
expandedChildren: ReadonlySet<string>;
|
|
160
164
|
expandedOlder: ReadonlySet<string>;
|
|
@@ -168,6 +172,8 @@ interface Context extends ViewInputs {
|
|
|
168
172
|
sectionNames: ReadonlyMap<string, string>;
|
|
169
173
|
/** The rows are drawn in Needs attention. */
|
|
170
174
|
drawnInAttention: boolean;
|
|
175
|
+
/** The rows are an attended family's: they draw as in its home group, with no bold title or why line. */
|
|
176
|
+
attended: boolean;
|
|
171
177
|
}
|
|
172
178
|
|
|
173
179
|
function titleOf(context: Context, id: string | null): string | null {
|
|
@@ -219,7 +225,8 @@ function threadRow(
|
|
|
219
225
|
nested: options.nested,
|
|
220
226
|
parentTitle: titleOf(context, info.parentId),
|
|
221
227
|
chip: options.chip,
|
|
222
|
-
|
|
228
|
+
bold: info.unread && !context.attended,
|
|
229
|
+
note: context.attended ? null : context.drawnInAttention && needsAttention ? info.attentionNote : info.note,
|
|
223
230
|
dimmed: isDimmed(context, info, options.chip),
|
|
224
231
|
hiddenBadge: info.thread.isHidden,
|
|
225
232
|
crossGroupLabel: crossGroupLabel(context, info, root),
|
|
@@ -421,7 +428,7 @@ function familyUnit(context: Context, family: Family): Unit {
|
|
|
421
428
|
}
|
|
422
429
|
|
|
423
430
|
/** Waiting on you, then failed, then offline, then unread, then the rest. */
|
|
424
|
-
export function urgency(family: Family): number {
|
|
431
|
+
export function urgency(family: Pick<Family, "attentionFlags">): number {
|
|
425
432
|
const flags = family.attentionFlags;
|
|
426
433
|
if (flags.has("waits-on-you")) return 0;
|
|
427
434
|
if (flags.has("unread-failed") || flags.has("queue-failed")) return 1;
|
|
@@ -437,7 +444,11 @@ export function urgency(family: Family): number {
|
|
|
437
444
|
* with the root's chip, it draws as in its home group and keeps the path.
|
|
438
445
|
*/
|
|
439
446
|
function attentionFamilyRows(groupContext: Context, family: Family, homeGroupLabel: string): Row[] {
|
|
440
|
-
const context: Context = {
|
|
447
|
+
const context: Context = {
|
|
448
|
+
...groupContext,
|
|
449
|
+
drawnInAttention: true,
|
|
450
|
+
attended: isAttended(family, groupContext.heldRootId),
|
|
451
|
+
};
|
|
441
452
|
const root = family.root;
|
|
442
453
|
const onPath = new Set<string>([root.thread.id]);
|
|
443
454
|
for (const info of family.descendants) {
|
|
@@ -475,20 +486,26 @@ function buildAttention(
|
|
|
475
486
|
): AttentionView | null {
|
|
476
487
|
if (families.length === 0) return null;
|
|
477
488
|
// Most urgent first, then the chosen field in its natural direction: the
|
|
478
|
-
// sort direction orders the groups only.
|
|
489
|
+
// sort direction orders the groups only. The held family sorts as it was
|
|
490
|
+
// when opened, so nothing inside it moves it.
|
|
479
491
|
const compare = makeComparator({
|
|
480
492
|
field: context.prefs.chronologicalSort,
|
|
481
493
|
direction: "default",
|
|
482
494
|
workingFirst: context.prefs.workingFirst,
|
|
483
495
|
});
|
|
484
|
-
const
|
|
485
|
-
|
|
496
|
+
const slotOf = (family: Family): SectionFamily =>
|
|
497
|
+
context.held !== null && family.root.thread.id === context.heldRootId ? context.held : family;
|
|
498
|
+
const sorted = [...families].sort((familyA, familyB) => {
|
|
499
|
+
const a = slotOf(familyA);
|
|
500
|
+
const b = slotOf(familyB);
|
|
501
|
+
return (
|
|
486
502
|
urgency(a) - urgency(b) ||
|
|
487
503
|
compare(
|
|
488
504
|
{ thread: a.root.thread, familyAttention: a.latestAttentionAt },
|
|
489
505
|
{ thread: b.root.thread, familyAttention: b.latestAttentionAt },
|
|
490
|
-
)
|
|
491
|
-
|
|
506
|
+
)
|
|
507
|
+
);
|
|
508
|
+
});
|
|
492
509
|
const homeGroupIds: Record<string, string> = {};
|
|
493
510
|
const rows = sorted.flatMap((family) => {
|
|
494
511
|
const home = homeGroupOf(family);
|
|
@@ -497,7 +514,7 @@ function buildAttention(
|
|
|
497
514
|
return familyRows;
|
|
498
515
|
});
|
|
499
516
|
return {
|
|
500
|
-
familyCount: sorted.length,
|
|
517
|
+
familyCount: sorted.filter((family) => !isAttended(family, context.heldRootId)).length,
|
|
501
518
|
rows,
|
|
502
519
|
homeGroupIds,
|
|
503
520
|
};
|
|
@@ -601,6 +618,7 @@ export function buildListView(inputs: ViewInputs): ListView {
|
|
|
601
618
|
}
|
|
602
619
|
const context: Context = {
|
|
603
620
|
...inputs,
|
|
621
|
+
heldRootId: inputs.held?.root.thread.id ?? null,
|
|
604
622
|
compare: makeComparator({
|
|
605
623
|
field: prefs.chronologicalSort,
|
|
606
624
|
direction: prefs.sortDirection,
|
|
@@ -615,6 +633,7 @@ export function buildListView(inputs: ViewInputs): ListView {
|
|
|
615
633
|
projectNames: new Map(inputs.projects.map((project) => [project.id, project.name])),
|
|
616
634
|
sectionNames: new Map(inputs.sections.map((section) => [section.id, section.name])),
|
|
617
635
|
drawnInAttention: false,
|
|
636
|
+
attended: false,
|
|
618
637
|
};
|
|
619
638
|
|
|
620
639
|
const byGroup = new Map<string, Family[]>();
|
|
@@ -630,7 +649,7 @@ export function buildListView(inputs: ViewInputs): ListView {
|
|
|
630
649
|
list.push(family);
|
|
631
650
|
byGroup.set(id, list);
|
|
632
651
|
homeOf.set(family, id);
|
|
633
|
-
if (inAttention(family,
|
|
652
|
+
if (inAttention(family, context.heldRootId, openRootId)) section.push(family);
|
|
634
653
|
}
|
|
635
654
|
const inSection = new Set(section);
|
|
636
655
|
|
|
@@ -8,6 +8,7 @@ import { defaultPreferences, type Preferences } from "@/shared/preferences";
|
|
|
8
8
|
import { buildForest, type Forest } from "../model/families";
|
|
9
9
|
import { buildListView, type ListView, type Row } from "../model/view";
|
|
10
10
|
import type { Targets } from "../model/expansion";
|
|
11
|
+
import type { SectionFamily } from "../model/attention";
|
|
11
12
|
|
|
12
13
|
export const T0 = 1_780_000_000_000;
|
|
13
14
|
|
|
@@ -108,7 +109,10 @@ export interface Scenario {
|
|
|
108
109
|
sections?: PluginSidebarSection[];
|
|
109
110
|
prefs?: Partial<Preferences>;
|
|
110
111
|
activeThreadId?: string | null;
|
|
112
|
+
/** The root of the held family, held as it stands now. */
|
|
111
113
|
heldRootId?: string | null;
|
|
114
|
+
/** The held family as it was when opened; wins over `heldRootId`. */
|
|
115
|
+
held?: SectionFamily | null;
|
|
112
116
|
targets?: Targets;
|
|
113
117
|
finishedAt?: Record<string, number>;
|
|
114
118
|
seenAt?: Record<string, number>;
|
|
@@ -141,7 +145,7 @@ export function viewOf(scenario: Scenario): ListView {
|
|
|
141
145
|
sections: scenario.sections ?? [],
|
|
142
146
|
prefs: { ...defaultPreferences(), ...scenario.prefs },
|
|
143
147
|
activeThreadId: scenario.activeThreadId ?? null,
|
|
144
|
-
heldRootId: scenario.heldRootId ?? null,
|
|
148
|
+
held: scenario.held ?? (scenario.heldRootId == null ? null : (forest.familyOf.get(scenario.heldRootId) ?? null)),
|
|
145
149
|
targets: scenario.targets ?? new Map(),
|
|
146
150
|
});
|
|
147
151
|
}
|