@gperezmz/bb-plugin-thread-glance 0.2.2 → 0.2.4
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/app.tsx +11 -2
- package/dist/app.css +1 -1
- package/dist/app.js +29 -29
- 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/FoldRows.tsx +1 -1
- package/features/thread-list/components/GroupSection.tsx +13 -15
- package/features/thread-list/components/{Toolbar.tsx → SettingsPanel.tsx} +19 -36
- package/features/thread-list/components/ThreadList.tsx +4 -6
- package/features/thread-list/components/ThreadRowView.tsx +1 -1
- package/features/thread-list/components/glyphs.tsx +13 -1
- package/features/thread-list/data/same-window.ts +34 -0
- package/features/thread-list/data/useAttentionHold.ts +7 -7
- package/features/thread-list/data/useClientPreferences.ts +15 -7
- package/features/thread-list/data/usePreferences.ts +16 -7
- package/features/thread-list/icons.ts +0 -1
- package/features/thread-list/index.ts +1 -0
- package/features/thread-list/model/attention.ts +29 -9
- package/features/thread-list/model/layout.ts +5 -2
- package/features/thread-list/model/settings.ts +1 -1
- package/features/thread-list/model/state.ts +6 -3
- 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
|
@@ -22,7 +22,7 @@ import { ROW_ICON_BUTTON } from "./ThreadRowView";
|
|
|
22
22
|
export const OlderRowView = memo(function OlderRowView({ row, controller }: { row: OlderRow; controller: RowController }) {
|
|
23
23
|
const { label, ariaLabel } = olderRowText(row);
|
|
24
24
|
// A family's fold sits where its children do, with a dots glyph in the
|
|
25
|
-
//
|
|
25
|
+
// Status column; the group's fold keeps the chevron.
|
|
26
26
|
const inFamily = row.scope !== "group";
|
|
27
27
|
return (
|
|
28
28
|
<button
|
|
@@ -43,8 +43,6 @@ export interface GroupController {
|
|
|
43
43
|
|
|
44
44
|
export type DropStates = ReadonlyMap<string, "valid" | "blocked" | "unchanged" | "before" | "after">;
|
|
45
45
|
|
|
46
|
-
/** The sticky toolbar's height; headers stick below it. */
|
|
47
|
-
export const TOOLBAR_HEIGHT = 28;
|
|
48
46
|
const ROW_HEIGHT = { compact: 28, comfortable: 44 };
|
|
49
47
|
|
|
50
48
|
function canRename(group: GroupView): boolean {
|
|
@@ -96,9 +94,8 @@ const GroupHeader = memo(function GroupHeader({
|
|
|
96
94
|
role={undefined}
|
|
97
95
|
tabIndex={undefined}
|
|
98
96
|
data-sidebar="group-label"
|
|
99
|
-
style={{ top: inOverflow ? 0 : TOOLBAR_HEIGHT }}
|
|
100
97
|
className={cn(
|
|
101
|
-
"group/header sticky z-20 flex h-7 items-center gap-1 rounded-md bg-sidebar pl-2 pr-1 text-xs text-muted-foreground max-md:pointer-coarse:h-9",
|
|
98
|
+
"group/header sticky top-0 z-20 flex h-7 items-center gap-1 rounded-md bg-sidebar pl-2 pr-1 text-xs text-muted-foreground max-md:pointer-coarse:h-9",
|
|
102
99
|
dropActive && "bg-sidebar-accent",
|
|
103
100
|
draggable.isDragging && "opacity-50",
|
|
104
101
|
!controller.compact && !inOverflow && "select-none",
|
|
@@ -423,24 +420,25 @@ const ATTENTION_COUNT =
|
|
|
423
420
|
"bg-[color-mix(in_oklab,var(--attention)_20%,transparent)] text-[color:color-mix(in_oklab,var(--attention),var(--foreground)_55%)]";
|
|
424
421
|
|
|
425
422
|
/**
|
|
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.
|
|
423
|
+
* The Needs attention section: a header with its family count (none when every
|
|
424
|
+
* family in it is attended), then its rows, on one band of bb's attention
|
|
425
|
+
* colour. It has no collapse, menu or drag, and no drop target of its own.
|
|
429
426
|
*/
|
|
430
427
|
export const AttentionSection = memo(function AttentionSection({ view, ...rest }: SectionProps & { view: AttentionView }) {
|
|
431
428
|
return (
|
|
432
429
|
<section aria-label="Needs attention" className={cn("relative mb-1 flex w-full min-w-0 flex-col rounded-md pb-0.5", ATTENTION_BAND)}>
|
|
433
430
|
<h2
|
|
434
|
-
|
|
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"
|
|
431
|
+
className="sticky top-0 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
432
|
>
|
|
437
433
|
<span className="min-w-0 flex-1 truncate">Needs attention</span>
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
434
|
+
{view.familyCount > 0 ? (
|
|
435
|
+
<span
|
|
436
|
+
className={cn("rounded-full px-1.5 text-[11px] leading-4 tabular-nums", ATTENTION_COUNT)}
|
|
437
|
+
aria-label={`${view.familyCount} ${view.familyCount === 1 ? "family" : "families"}`}
|
|
438
|
+
>
|
|
439
|
+
{view.familyCount}
|
|
440
|
+
</span>
|
|
441
|
+
) : null}
|
|
444
442
|
</h2>
|
|
445
443
|
<Rows {...rest} rows={view.rows} groupId={ATTENTION_GROUP_ID} inPinned={false} forceMount={false} />
|
|
446
444
|
</section>
|
|
@@ -1,20 +1,21 @@
|
|
|
1
|
-
//
|
|
2
|
-
import { experimental_Icon as Icon } from "@get-bb/plugin-sdk/app";
|
|
3
|
-
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
1
|
+
// Thread Glance's settings, shown by its item in bb's sidebar footer.
|
|
2
|
+
import { experimental_Icon as Icon, type ExperimentalSidebarFooterDisclosureProps } from "@get-bb/plugin-sdk/app";
|
|
4
3
|
import { cn } from "@/lib/utils";
|
|
5
4
|
import type { ClientPreferences, Preferences } from "@/shared/preferences";
|
|
6
5
|
import { childAttentionFor, countsEveryChild, lifecyclesFor, sortArrow, sortFieldPatch, threadsShown } from "../model/settings";
|
|
7
6
|
import { effectiveSortField } from "../model/sort";
|
|
7
|
+
import { useClientPreferences } from "../data/useClientPreferences";
|
|
8
|
+
import { usePreferences } from "../data/usePreferences";
|
|
8
9
|
import { ICONS } from "../icons";
|
|
9
|
-
import { TOOLBAR_HEIGHT } from "./GroupSection";
|
|
10
|
-
import { ROW_ICON_BUTTON } from "./ThreadRowView";
|
|
11
10
|
|
|
12
11
|
// bb's own segmented controls (the Reasoning picker, the diff view toggle)
|
|
13
12
|
// mark the chosen item with the state-active token and no shadow. The track
|
|
14
13
|
// is a translucent tint, so the chosen item reads as raised in both themes.
|
|
15
|
-
|
|
14
|
+
// An item never shrinks below its text: where a line cannot hold its label
|
|
15
|
+
// and its control, the control wraps onto a line of its own.
|
|
16
|
+
const SEGMENT_TRACK = "flex flex-1 gap-0.5 rounded-md bg-surface-recessed p-0.5";
|
|
16
17
|
const SEGMENT_ITEM =
|
|
17
|
-
"flex
|
|
18
|
+
"flex flex-1 items-center justify-center whitespace-nowrap rounded-sm px-1 py-0.5 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring";
|
|
18
19
|
|
|
19
20
|
function segmentClass(selected: boolean): string {
|
|
20
21
|
return cn(
|
|
@@ -25,12 +26,12 @@ function segmentClass(selected: boolean): string {
|
|
|
25
26
|
);
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
/** A label, then its control
|
|
29
|
+
/** A label, then its control: on one line where the panel is wide enough, else the control below. */
|
|
29
30
|
function Line({ label, children }: { label: string; children: React.ReactNode }) {
|
|
30
31
|
return (
|
|
31
|
-
<div className="flex items-center gap-2 px-2 py-1">
|
|
32
|
-
<span className="w-[5.5rem] shrink-0 text-sm">{label}</span>
|
|
33
|
-
{children}
|
|
32
|
+
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-2 py-1">
|
|
33
|
+
<span className="min-w-[5.5rem] shrink-0 whitespace-nowrap text-sm">{label}</span>
|
|
34
|
+
<div className="flex flex-1 gap-2">{children}</div>
|
|
34
35
|
</div>
|
|
35
36
|
);
|
|
36
37
|
}
|
|
@@ -104,7 +105,7 @@ function Heading({ children }: { children: React.ReactNode }) {
|
|
|
104
105
|
return <h3 className="px-2 pb-0.5 pt-3 text-xs font-medium text-muted-foreground first:pt-0">{children}</h3>;
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
|
|
108
|
+
function SettingsPanel({
|
|
108
109
|
prefs,
|
|
109
110
|
client,
|
|
110
111
|
onPrefs,
|
|
@@ -208,31 +209,13 @@ export function SettingsPanel({
|
|
|
208
209
|
);
|
|
209
210
|
}
|
|
210
211
|
|
|
211
|
-
/** The
|
|
212
|
-
export function
|
|
213
|
-
prefs,
|
|
214
|
-
client,
|
|
215
|
-
onPrefs,
|
|
216
|
-
onClient,
|
|
217
|
-
}: {
|
|
218
|
-
prefs: Preferences;
|
|
219
|
-
client: ClientPreferences;
|
|
220
|
-
onPrefs(patch: Partial<Preferences>): void;
|
|
221
|
-
onClient(patch: Partial<ClientPreferences>): void;
|
|
222
|
-
}) {
|
|
212
|
+
/** The footer item's panel, reading and writing the preferences the list reads. */
|
|
213
|
+
export function SettingsDisclosure(_props: ExperimentalSidebarFooterDisclosureProps) {
|
|
214
|
+
const { prefs, update } = usePreferences();
|
|
215
|
+
const [client, updateClient] = useClientPreferences();
|
|
223
216
|
return (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
<Popover>
|
|
227
|
-
<PopoverTrigger asChild>
|
|
228
|
-
<button type="button" aria-label="Thread Glance settings" title="List settings" className={ROW_ICON_BUTTON}>
|
|
229
|
-
<Icon name={ICONS.settings} aria-hidden className="size-4" />
|
|
230
|
-
</button>
|
|
231
|
-
</PopoverTrigger>
|
|
232
|
-
<PopoverContent align="end" className="w-80 p-2">
|
|
233
|
-
<SettingsPanel prefs={prefs} client={client} onPrefs={onPrefs} onClient={onClient} />
|
|
234
|
-
</PopoverContent>
|
|
235
|
-
</Popover>
|
|
217
|
+
<div className="p-1">
|
|
218
|
+
<SettingsPanel prefs={prefs} client={client} onPrefs={update} onClient={updateClient} />
|
|
236
219
|
</div>
|
|
237
220
|
);
|
|
238
221
|
}
|
|
@@ -54,7 +54,6 @@ import { cancelPendingCards } from "./row-card";
|
|
|
54
54
|
import { GroupSection, AttentionSection, type DropStates, type GroupController } from "./GroupSection";
|
|
55
55
|
import type { ProviderDisplay } from "./ProviderBadge";
|
|
56
56
|
import { ThreadDetails } from "./ThreadDetails";
|
|
57
|
-
import { Toolbar } from "./Toolbar";
|
|
58
57
|
|
|
59
58
|
const PLUGIN_ID = "thread-glance";
|
|
60
59
|
|
|
@@ -108,7 +107,7 @@ function ThreadListBody({
|
|
|
108
107
|
onRetry,
|
|
109
108
|
}: PluginThreadListProps & { attempt: number; onRetry(): void }) {
|
|
110
109
|
const { prefs, hydrated, update } = usePreferences();
|
|
111
|
-
const [client
|
|
110
|
+
const [client] = useClientPreferences();
|
|
112
111
|
const sidebar = useSidebarThreads({ experimental_lifecycles: prefs.threadLifecycles });
|
|
113
112
|
const actions = useThreadActions();
|
|
114
113
|
const sdk = useSdk();
|
|
@@ -160,7 +159,7 @@ function ThreadListBody({
|
|
|
160
159
|
[ready, threads, activeThreadId, stamps.finishedAt, stamps.seenAt, draftIds, scheduled, now, notes, prefs.childAttention],
|
|
161
160
|
);
|
|
162
161
|
const { targets, prune } = useAutoExpand(hydrated ? forest : null, activeThreadId);
|
|
163
|
-
const
|
|
162
|
+
const held = useAttentionHold(forest, activeThreadId);
|
|
164
163
|
// Rows and groups that did not change keep their objects, so their
|
|
165
164
|
// memoized components skip the render.
|
|
166
165
|
const previousView = useRef<ListView | null>(null);
|
|
@@ -175,10 +174,10 @@ function ThreadListBody({
|
|
|
175
174
|
sections: sidebar.sections,
|
|
176
175
|
prefs,
|
|
177
176
|
activeThreadId,
|
|
178
|
-
|
|
177
|
+
held,
|
|
179
178
|
targets,
|
|
180
179
|
})),
|
|
181
|
-
[forest, threads, sidebar.projects, sidebar.sections, prefs, activeThreadId,
|
|
180
|
+
[forest, threads, sidebar.projects, sidebar.sections, prefs, activeThreadId, held, targets],
|
|
182
181
|
);
|
|
183
182
|
useLayoutEffect(() => {
|
|
184
183
|
previousView.current = view;
|
|
@@ -679,7 +678,6 @@ function ThreadListBody({
|
|
|
679
678
|
return (
|
|
680
679
|
<ListLiveContext.Provider value={live}>
|
|
681
680
|
<div className="flex w-full min-w-0 flex-col px-1.5 pb-2">
|
|
682
|
-
<Toolbar prefs={prefs} client={client} onPrefs={update} onClient={updateClient} />
|
|
683
681
|
{threads.length === 0 ? (
|
|
684
682
|
// bb's own pinned New thread button covers the empty list.
|
|
685
683
|
<p className="px-3 py-4 text-sm text-muted-foreground">No threads yet.</p>
|
|
@@ -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.
|
|
@@ -47,6 +47,18 @@ export function UnreadDot({ label, className }: { label?: string; className?: st
|
|
|
47
47
|
);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// The Idle state's ring: uncoloured, smaller than the column and thinned, so
|
|
51
|
+
// it reads as status yet stays fainter than the unread dot and the draft
|
|
52
|
+
// pencil. Screen readers skip it, so an Idle row announces what it did before.
|
|
53
|
+
export function IdleRing({ className }: { className?: string }) {
|
|
54
|
+
return (
|
|
55
|
+
<span
|
|
56
|
+
aria-hidden
|
|
57
|
+
className={cn("inline-block size-2.5 shrink-0 rounded-full border-[1.5px] border-current opacity-50", className)}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
50
62
|
export function GlyphIcon({
|
|
51
63
|
glyph,
|
|
52
64
|
label,
|
|
@@ -56,7 +68,7 @@ export function GlyphIcon({
|
|
|
56
68
|
label?: string;
|
|
57
69
|
className?: string;
|
|
58
70
|
}) {
|
|
59
|
-
if (glyph.icon ===
|
|
71
|
+
if (glyph.icon === "ring") return <IdleRing className={TONE_CLASS[glyph.tone]} />;
|
|
60
72
|
if (glyph.icon === "dot") return <UnreadDot label={label} />;
|
|
61
73
|
return (
|
|
62
74
|
<Icon
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The list and the settings panel mount apart, each with its own copy of the
|
|
2
|
+
// preferences. A change made in one reaches the other here, in the same
|
|
3
|
+
// window, at once; the server and localStorage stay what other windows read.
|
|
4
|
+
import { useCallback, useEffect } from "react";
|
|
5
|
+
|
|
6
|
+
type Apply<Patch> = (patch: Patch) => void;
|
|
7
|
+
|
|
8
|
+
export function sameWindow<Patch>(): Set<Apply<Patch>> {
|
|
9
|
+
return new Set();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Joins this copy to `copies` for as long as it is mounted.
|
|
14
|
+
*
|
|
15
|
+
* Returns the call that hands a patch this copy made to every other copy.
|
|
16
|
+
*/
|
|
17
|
+
export function useSameWindow<State extends object>(
|
|
18
|
+
copies: Set<Apply<Partial<State>>>,
|
|
19
|
+
setState: (update: (current: State) => State) => void,
|
|
20
|
+
): Apply<Partial<State>> {
|
|
21
|
+
const apply = useCallback((patch: Partial<State>) => setState((current) => ({ ...current, ...patch })), [setState]);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
copies.add(apply);
|
|
24
|
+
return () => {
|
|
25
|
+
copies.delete(apply);
|
|
26
|
+
};
|
|
27
|
+
}, [copies, apply]);
|
|
28
|
+
return useCallback(
|
|
29
|
+
(patch: Partial<State>) => {
|
|
30
|
+
for (const other of copies) if (other !== apply) other(patch);
|
|
31
|
+
},
|
|
32
|
+
[copies, apply],
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -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
|
}
|
|
@@ -5,16 +5,24 @@ import {
|
|
|
5
5
|
parseClientPreferences,
|
|
6
6
|
type ClientPreferences,
|
|
7
7
|
} from "@/shared/preferences";
|
|
8
|
+
import { sameWindow, useSameWindow } from "./same-window";
|
|
8
9
|
import { readJson, writeJson } from "./storage";
|
|
9
10
|
|
|
11
|
+
const copies = sameWindow<Partial<ClientPreferences>>();
|
|
12
|
+
|
|
10
13
|
export function useClientPreferences(): [ClientPreferences, (patch: Partial<ClientPreferences>) => void] {
|
|
11
14
|
const [value, setValue] = useState(() => parseClientPreferences(readJson(CLIENT_PREFERENCES_STORAGE_KEY)));
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
const tellOthers = useSameWindow(copies, setValue);
|
|
16
|
+
const update = useCallback(
|
|
17
|
+
(patch: Partial<ClientPreferences>) => {
|
|
18
|
+
setValue((current) => {
|
|
19
|
+
const next = { ...current, ...patch };
|
|
20
|
+
writeJson(CLIENT_PREFERENCES_STORAGE_KEY, next);
|
|
21
|
+
return next;
|
|
22
|
+
});
|
|
23
|
+
tellOthers(patch);
|
|
24
|
+
},
|
|
25
|
+
[tellOthers],
|
|
26
|
+
);
|
|
19
27
|
return [value, update];
|
|
20
28
|
}
|
|
@@ -14,10 +14,16 @@ import {
|
|
|
14
14
|
type PreferenceKey,
|
|
15
15
|
type Preferences,
|
|
16
16
|
} from "@/shared/preferences";
|
|
17
|
+
import { sameWindow, useSameWindow } from "./same-window";
|
|
17
18
|
import { readJson, writeJson } from "./storage";
|
|
18
19
|
|
|
19
20
|
const WRITE_DEBOUNCE_MS = 150;
|
|
20
21
|
|
|
22
|
+
const copies = sameWindow<Partial<Preferences>>();
|
|
23
|
+
// Writes not yet sent, whichever copy made them: a reload or an echo in any
|
|
24
|
+
// copy must not put back the value one of them replaced.
|
|
25
|
+
const pending = new Map<PreferenceKey, unknown>();
|
|
26
|
+
|
|
21
27
|
export interface PreferencesState {
|
|
22
28
|
prefs: Preferences;
|
|
23
29
|
/** The server's values have arrived (or failed; the mirror stands in). */
|
|
@@ -31,7 +37,6 @@ export function usePreferences(): PreferencesState {
|
|
|
31
37
|
coercePreferences(readJson(PREFERENCES_MIRROR_STORAGE_KEY)),
|
|
32
38
|
);
|
|
33
39
|
const [hydrated, setHydrated] = useState(false);
|
|
34
|
-
const pending = useRef(new Map<PreferenceKey, unknown>());
|
|
35
40
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
36
41
|
const connection = useRealtimeConnectionState();
|
|
37
42
|
const wasConnected = useRef(false);
|
|
@@ -41,7 +46,7 @@ export function usePreferences(): PreferencesState {
|
|
|
41
46
|
const { preferences } = await rpc.call("listPreferences", null);
|
|
42
47
|
const next = coercePreferences(preferences);
|
|
43
48
|
// Keys with a write in flight keep the local value.
|
|
44
|
-
for (const [key, value] of pending
|
|
49
|
+
for (const [key, value] of pending) (next as Record<string, unknown>)[key] = value;
|
|
45
50
|
setPrefs(next);
|
|
46
51
|
writeJson(PREFERENCES_MIRROR_STORAGE_KEY, next);
|
|
47
52
|
} finally {
|
|
@@ -73,7 +78,7 @@ export function usePreferences(): PreferencesState {
|
|
|
73
78
|
|
|
74
79
|
useRealtime(CHANNELS.preferences, (payload) => {
|
|
75
80
|
const signal = payload as PreferenceSignal;
|
|
76
|
-
if (!isPreferenceKey(signal?.key) || pending.
|
|
81
|
+
if (!isPreferenceKey(signal?.key) || pending.has(signal.key)) return;
|
|
77
82
|
const parsed = parsePreference(signal.key, signal.value);
|
|
78
83
|
if (!parsed.success) return;
|
|
79
84
|
setPrefs((current) => {
|
|
@@ -84,8 +89,8 @@ export function usePreferences(): PreferencesState {
|
|
|
84
89
|
});
|
|
85
90
|
|
|
86
91
|
const flush = useCallback(() => {
|
|
87
|
-
const writes = [...pending
|
|
88
|
-
pending.
|
|
92
|
+
const writes = [...pending];
|
|
93
|
+
pending.clear();
|
|
89
94
|
for (const [key, value] of writes) {
|
|
90
95
|
rpc.call("setPreference", { key, value }).catch((error: unknown) => {
|
|
91
96
|
toast.error(`Couldn't save the ${key} setting`, {
|
|
@@ -103,6 +108,9 @@ export function usePreferences(): PreferencesState {
|
|
|
103
108
|
[flush],
|
|
104
109
|
);
|
|
105
110
|
|
|
111
|
+
// Another copy's change arrives already mirrored and on its way to the server.
|
|
112
|
+
const tellOthers = useSameWindow(copies, setPrefs);
|
|
113
|
+
|
|
106
114
|
const update = useCallback(
|
|
107
115
|
(patch: Partial<Preferences>) => {
|
|
108
116
|
setPrefs((current) => {
|
|
@@ -110,8 +118,9 @@ export function usePreferences(): PreferencesState {
|
|
|
110
118
|
writeJson(PREFERENCES_MIRROR_STORAGE_KEY, next);
|
|
111
119
|
return next;
|
|
112
120
|
});
|
|
121
|
+
tellOthers(patch);
|
|
113
122
|
for (const [key, value] of Object.entries(patch)) {
|
|
114
|
-
if (isPreferenceKey(key)) pending.
|
|
123
|
+
if (isPreferenceKey(key)) pending.set(key, value);
|
|
115
124
|
}
|
|
116
125
|
if (timer.current !== null) clearTimeout(timer.current);
|
|
117
126
|
timer.current = setTimeout(() => {
|
|
@@ -119,7 +128,7 @@ export function usePreferences(): PreferencesState {
|
|
|
119
128
|
flush();
|
|
120
129
|
}, WRITE_DEBOUNCE_MS);
|
|
121
130
|
},
|
|
122
|
-
[flush],
|
|
131
|
+
[flush, tellOthers],
|
|
123
132
|
);
|
|
124
133
|
|
|
125
134
|
return { prefs, hydrated, update };
|
|
@@ -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
|
}
|
|
@@ -1,9 +1,12 @@
|
|
|
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
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Left padding of a root row: its Status column starts where the group
|
|
5
|
+
* header's text does.
|
|
6
|
+
*/
|
|
4
7
|
export const ROOT_INDENT = 8;
|
|
5
8
|
/**
|
|
6
|
-
* A child's
|
|
9
|
+
* A child's Status column starts this far right of its parent's, so the
|
|
7
10
|
* child's title sits this far right of the parent's title.
|
|
8
11
|
*/
|
|
9
12
|
export const FOLDED_STEP = 12;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// What the settings
|
|
1
|
+
// What the settings panel's controls show and write. Pure.
|
|
2
2
|
import type { ChildAttention, Lifecycle, Preferences, SortField } from "@/shared/preferences";
|
|
3
3
|
import { effectiveDirection, effectiveSortField, naturalDirection } from "./sort";
|
|
4
4
|
|
|
@@ -48,9 +48,12 @@ const NEEDS: Record<NeedsKind, { icon: string; label: string }> = {
|
|
|
48
48
|
input: { icon: "MessageQuestion", label: "Needs your input" },
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* How a glyph is drawn. `dot` is the unread dot, `ring` the Idle state's
|
|
53
|
+
* faint ring; everything else a host icon.
|
|
54
|
+
*/
|
|
52
55
|
export interface Glyph {
|
|
53
|
-
icon: string | "dot" |
|
|
56
|
+
icon: string | "dot" | "ring";
|
|
54
57
|
tone: Tone;
|
|
55
58
|
spin: boolean;
|
|
56
59
|
shine: boolean;
|
|
@@ -231,7 +234,7 @@ export function computeState(thread: PluginSidebarThread, inputs: StateInputs):
|
|
|
231
234
|
}
|
|
232
235
|
if (inputs.unread) return { kind: "unread", label: "Unread", glyph: glyph("dot", "none"), ...none };
|
|
233
236
|
if (inputs.hasDraft) return { kind: "draft", label: "Unsubmitted draft", glyph: glyph("Edit", "muted"), ...none };
|
|
234
|
-
return { kind: "idle", label: "Idle", glyph: glyph(
|
|
237
|
+
return { kind: "idle", label: "Idle", glyph: glyph("ring", "muted"), ...none };
|
|
235
238
|
}
|
|
236
239
|
|
|
237
240
|
/**
|