@gperezmz/bb-plugin-thread-glance 0.4.0 → 0.4.2
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.css +1 -1
- package/dist/app.js +25 -25
- package/dist/app.meta.json +1 -1
- package/dist/server.js +10 -10
- package/dist/server.js.map +3 -3
- package/dist/server.meta.json +1 -1
- package/features/thread-list/components/GroupSection.tsx +10 -7
- package/features/thread-list/components/ListHeader.tsx +8 -3
- package/features/thread-list/components/ThreadList.tsx +36 -24
- package/features/thread-list/components/ThreadRowView.tsx +5 -4
- package/features/thread-list/components/controller.ts +1 -1
- package/features/thread-list/components/input-modality.ts +51 -0
- package/features/thread-list/components/row-card.ts +1 -1
- package/features/thread-list/data/useIdleSince.ts +27 -0
- package/features/thread-list/data/useNow.ts +4 -3
- package/features/thread-list/data/useStamps.ts +19 -4
- package/features/thread-list/model/attention.ts +71 -7
- package/features/thread-list/model/expansion.ts +1 -1
- package/features/thread-list/model/settled.ts +5 -67
- package/features/thread-list/model/state.ts +15 -2
- package/features/thread-list/model/toggles.ts +3 -3
- package/features/thread-list/model/trees.ts +46 -9
- package/features/thread-list/model/view.ts +10 -4
- package/features/thread-list/testing/fixtures.ts +11 -7
- package/package.json +1 -1
- package/server/stamps.ts +21 -3
- package/server.ts +5 -0
- package/shared/contract.ts +8 -0
- package/shared/signals.ts +2 -0
- package/skills/thread-glance/SKILL.md +2 -2
- package/features/thread-list/components/PullRequestProbes.tsx +0 -27
- package/features/thread-list/data/usePullRequestAnswers.ts +0 -36
package/dist/server.meta.json
CHANGED
|
@@ -21,6 +21,7 @@ import { EnvironmentRowView, OlderRowView, SettledRowView } from "./FoldRows";
|
|
|
21
21
|
import { CounterStrip } from "./glyphs";
|
|
22
22
|
import { visibleCounters } from "../model/counters";
|
|
23
23
|
import { RenameEditor } from "./RenameEditor";
|
|
24
|
+
import { HEADER_HOVER_HIDES, HEADER_HOVER_SHOWS } from "./input-modality";
|
|
24
25
|
import { ROW_ICON_BUTTON, ThreadRowView } from "./ThreadRowView";
|
|
25
26
|
|
|
26
27
|
export interface GroupController {
|
|
@@ -70,13 +71,13 @@ const GroupHeader = memo(function GroupHeader({
|
|
|
70
71
|
});
|
|
71
72
|
const compact = controller.compact;
|
|
72
73
|
// Desktop: the counter sits flush right, and "+" and "…" fade in over its
|
|
73
|
-
// place on hover or focus, as a row's actions fade over its age. Nothing is
|
|
74
|
+
// place on hover or keyboard focus, as a row's actions fade over its age. Nothing is
|
|
74
75
|
// kept for them otherwise. Phones keep them in line, always shown.
|
|
75
76
|
const counterFade = compact
|
|
76
77
|
? ""
|
|
77
78
|
: menuOpen
|
|
78
79
|
? "opacity-0"
|
|
79
|
-
:
|
|
80
|
+
: HEADER_HOVER_HIDES;
|
|
80
81
|
const actionsFade = compact
|
|
81
82
|
? ""
|
|
82
83
|
: cn(
|
|
@@ -84,7 +85,7 @@ const GroupHeader = memo(function GroupHeader({
|
|
|
84
85
|
dropActive ? "bg-sidebar-accent" : "bg-sidebar",
|
|
85
86
|
menuOpen
|
|
86
87
|
? "opacity-100"
|
|
87
|
-
:
|
|
88
|
+
: HEADER_HOVER_SHOWS,
|
|
88
89
|
);
|
|
89
90
|
return (
|
|
90
91
|
<div
|
|
@@ -163,10 +164,12 @@ const GroupHeader = memo(function GroupHeader({
|
|
|
163
164
|
</button>
|
|
164
165
|
</DropdownMenuTrigger>
|
|
165
166
|
<DropdownMenuContent align="end" className="min-w-48">
|
|
166
|
-
|
|
167
|
-
<
|
|
168
|
-
|
|
169
|
-
|
|
167
|
+
{group.hasUnread ? (
|
|
168
|
+
<DropdownMenuItem onSelect={() => controller.onMarkAllRead(group)}>
|
|
169
|
+
<Icon name={ICONS.markRead} aria-hidden className="size-4" />
|
|
170
|
+
Mark all read
|
|
171
|
+
</DropdownMenuItem>
|
|
172
|
+
) : null}
|
|
170
173
|
{group.descriptor.kind !== "pinned" && group.descriptor.newThreadProjectId !== null ? (
|
|
171
174
|
<DropdownMenuItem onSelect={() => controller.onNewThread(group)}>
|
|
172
175
|
<Icon name={ICONS.newThread} aria-hidden className="size-4" />
|
|
@@ -22,6 +22,8 @@ export interface ListHeaderProps {
|
|
|
22
22
|
needYouCount: number;
|
|
23
23
|
needYouOnly: boolean;
|
|
24
24
|
onToggleNeedYou(): void;
|
|
25
|
+
/** A thread in the list is unread; Mark all read is not drawn otherwise. */
|
|
26
|
+
hasUnread: boolean;
|
|
25
27
|
onMarkAllRead(): void;
|
|
26
28
|
prefs: Preferences;
|
|
27
29
|
client: ClientPreferences;
|
|
@@ -34,6 +36,7 @@ export const ListHeader = memo(function ListHeader({
|
|
|
34
36
|
needYouCount,
|
|
35
37
|
needYouOnly,
|
|
36
38
|
onToggleNeedYou,
|
|
39
|
+
hasUnread,
|
|
37
40
|
onMarkAllRead,
|
|
38
41
|
prefs,
|
|
39
42
|
client,
|
|
@@ -61,9 +64,11 @@ export const ListHeader = memo(function ListHeader({
|
|
|
61
64
|
{needYouCount} need you
|
|
62
65
|
</button>
|
|
63
66
|
) : null}
|
|
64
|
-
|
|
65
|
-
<
|
|
66
|
-
|
|
67
|
+
{hasUnread ? (
|
|
68
|
+
<button type="button" aria-label="Mark all read" title="Mark all read" className={ROW_ICON_BUTTON} onClick={onMarkAllRead}>
|
|
69
|
+
<Icon name={ICONS.markRead} aria-hidden className="size-4" />
|
|
70
|
+
</button>
|
|
71
|
+
) : null}
|
|
67
72
|
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
|
|
68
73
|
<PopoverTrigger asChild>
|
|
69
74
|
<button type="button" aria-label="Thread Glance settings" title="Settings" className={ROW_ICON_BUTTON}>
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
experimental_useSidebarThreads as useSidebarThreads,
|
|
21
21
|
useEnvironmentProviders,
|
|
22
22
|
useSdk,
|
|
23
|
+
useSidebarSplitLayout,
|
|
23
24
|
useSidebarThreadDraftIds,
|
|
24
25
|
} from "@get-bb/plugin-sdk/app";
|
|
25
26
|
import type { PluginSidebarThread, PluginThreadListProps } from "@get-bb/plugin-sdk/app";
|
|
@@ -29,6 +30,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
|
|
29
30
|
import { cn } from "@/lib/utils";
|
|
30
31
|
import { useAutoExpand } from "../data/useAutoExpand";
|
|
31
32
|
import { useClientPreferences } from "../data/useClientPreferences";
|
|
33
|
+
import { useIdleSince } from "../data/useIdleSince";
|
|
32
34
|
import { useNow } from "../data/useNow";
|
|
33
35
|
import { usePreferences } from "../data/usePreferences";
|
|
34
36
|
import { useScheduled } from "../data/useScheduled";
|
|
@@ -39,9 +41,7 @@ import { moveGroup, ORDER_PREFERENCE } from "../model/groups";
|
|
|
39
41
|
import { MARK_ALL_CONFIRM_ABOVE, type RowMenuAction } from "../model/menu";
|
|
40
42
|
import { assignProviderMarks, providerMark } from "../model/provider-mark";
|
|
41
43
|
import { markAllReadPlan, markReadPlanFor, toggleChip, toggleGroup, toggleOlder, toggleSettled, type ToggleOutcome } from "../model/toggles";
|
|
42
|
-
import {
|
|
43
|
-
import { usePullRequestAnswers } from "../data/usePullRequestAnswers";
|
|
44
|
-
import { PullRequestProbes } from "./PullRequestProbes";
|
|
44
|
+
import type { SettleInputs } from "../model/settled";
|
|
45
45
|
import { buildListView, countNeedYou, needYouActive, type GroupView, type ListView } from "../model/view";
|
|
46
46
|
import { shareView } from "../model/share";
|
|
47
47
|
import { ListLiveContext, type ListLive, type ModelInfo, type RowController } from "./controller";
|
|
@@ -54,6 +54,7 @@ import { modelDisplayName } from "../model/details";
|
|
|
54
54
|
import { groupIdForRoot } from "../model/groups";
|
|
55
55
|
import { CounterStrip } from "./glyphs";
|
|
56
56
|
import { cancelPendingCards } from "./row-card";
|
|
57
|
+
import { useInputModality } from "./input-modality";
|
|
57
58
|
import { GroupSection, type DropStates, type GroupController } from "./GroupSection";
|
|
58
59
|
import type { ProviderDisplay } from "./ProviderBadge";
|
|
59
60
|
import { ThreadDetails } from "./ThreadDetails";
|
|
@@ -115,19 +116,31 @@ function ThreadListBody({
|
|
|
115
116
|
const [client, updateClient] = useClientPreferences();
|
|
116
117
|
// The need-you filter is per window and starts off on every load.
|
|
117
118
|
const [needYouOn, setNeedYouOn] = useState(false);
|
|
119
|
+
const root = useRef<HTMLDivElement>(null);
|
|
120
|
+
useInputModality(root);
|
|
118
121
|
const sidebar = useSidebarThreads({ experimental_lifecycles: prefs.showArchived ? ["active", "archived"] : ["active"] });
|
|
119
122
|
const actions = useThreadActions();
|
|
120
123
|
const sdk = useSdk();
|
|
121
124
|
const { providers } = useProviders();
|
|
122
125
|
const { providers: environmentProviders } = useEnvironmentProviders();
|
|
123
126
|
const draftIds = useSidebarThreadDraftIds();
|
|
124
|
-
const
|
|
127
|
+
const splitLayout = useSidebarSplitLayout();
|
|
128
|
+
// Every thread a split pane shows is open, not only the focused one.
|
|
129
|
+
const openThreadIds = useMemo(
|
|
130
|
+
() => new Set(splitLayout?.panes.flatMap((pane) => (pane.threadId === null ? [] : [pane.threadId])) ?? []),
|
|
131
|
+
[splitLayout],
|
|
132
|
+
);
|
|
133
|
+
const { stamps, loaded: stampsLoaded, markSeen, markIdle, clearSeen } = useStamps();
|
|
125
134
|
const scheduled = useScheduled();
|
|
126
135
|
const notes = useNotes();
|
|
136
|
+
// When the next child's failure becomes orphaned. The forest knows it, but
|
|
137
|
+
// the forest is built from the clock, so the clock takes it from state.
|
|
138
|
+
const [orphanDeadline, setOrphanDeadline] = useState<number | null>(null);
|
|
127
139
|
const nextDeadline = useMemo(() => {
|
|
128
140
|
const future = Object.values(scheduled).filter((at) => at > Date.now());
|
|
141
|
+
if (orphanDeadline !== null) future.push(orphanDeadline);
|
|
129
142
|
return future.length > 0 ? Math.min(...future) : null;
|
|
130
|
-
}, [scheduled]);
|
|
143
|
+
}, [scheduled, orphanDeadline]);
|
|
131
144
|
const now = useNow(nextDeadline);
|
|
132
145
|
|
|
133
146
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
@@ -145,6 +158,7 @@ function ThreadListBody({
|
|
|
145
158
|
const ready = sidebar.status === "ready";
|
|
146
159
|
const threads = sidebar.threads;
|
|
147
160
|
const byId = useMemo(() => new Map(threads.map((thread) => [thread.id, thread])), [threads]);
|
|
161
|
+
const idleSince = useIdleSince(threads, markIdle);
|
|
148
162
|
|
|
149
163
|
const forest = useMemo(
|
|
150
164
|
() =>
|
|
@@ -152,6 +166,7 @@ function ThreadListBody({
|
|
|
152
166
|
? buildForest({
|
|
153
167
|
threads,
|
|
154
168
|
activeThreadId,
|
|
169
|
+
openThreadIds,
|
|
155
170
|
finishedAt: stamps.finishedAt,
|
|
156
171
|
seenAt: stamps.seenAt,
|
|
157
172
|
draftIds,
|
|
@@ -159,10 +174,15 @@ function ThreadListBody({
|
|
|
159
174
|
now,
|
|
160
175
|
notes,
|
|
161
176
|
childAttention: prefs.childAttention,
|
|
177
|
+
idleSince,
|
|
178
|
+
idleAt: stamps.idleAt,
|
|
179
|
+
stampsLoaded,
|
|
162
180
|
})
|
|
163
181
|
: null,
|
|
164
|
-
[ready, threads, activeThreadId, stamps.finishedAt, stamps.seenAt, draftIds, scheduled, now, notes, prefs.childAttention],
|
|
182
|
+
[ready, threads, activeThreadId, openThreadIds, stamps.finishedAt, stamps.seenAt, stamps.idleAt, stampsLoaded, draftIds, scheduled, now, notes, prefs.childAttention, idleSince],
|
|
165
183
|
);
|
|
184
|
+
const nextOrphanAt = forest?.nextOrphanAt ?? null;
|
|
185
|
+
if (nextOrphanAt !== orphanDeadline) setOrphanDeadline(nextOrphanAt);
|
|
166
186
|
const { targets, prune } = useAutoExpand(hydrated ? forest : null, activeThreadId);
|
|
167
187
|
// Projects with a thread on a branch: the rest need no default branch.
|
|
168
188
|
const branchedProjectIds = useMemo(
|
|
@@ -174,21 +194,15 @@ function ThreadListBody({
|
|
|
174
194
|
(thread: PluginSidebarThread) => (defaultBranches.has(thread.projectId) ? (defaultBranches.get(thread.projectId) ?? null) : undefined),
|
|
175
195
|
[defaultBranches],
|
|
176
196
|
);
|
|
177
|
-
const [pullRequests, onPullRequest] = usePullRequestAnswers();
|
|
178
197
|
const system = useSystemFacts();
|
|
179
|
-
const pullRequestLookups = useMemo(
|
|
180
|
-
() => (forest === null ? [] : pullRequestLookupIds(forest.infos.values(), defaultBranchOf)),
|
|
181
|
-
[forest, defaultBranchOf],
|
|
182
|
-
);
|
|
183
198
|
const settle: SettleInputs = useMemo(
|
|
184
199
|
() => ({
|
|
185
200
|
now,
|
|
186
201
|
settleAfter: prefs.settleAfter,
|
|
187
202
|
startedAt: stamps.startedAt,
|
|
188
203
|
finishedAt: stamps.finishedAt,
|
|
189
|
-
pullRequestOf: (thread) => pullRequestFact(thread, defaultBranchOf(thread), pullRequests),
|
|
190
204
|
}),
|
|
191
|
-
[now, prefs.settleAfter, stamps.startedAt, stamps.finishedAt
|
|
205
|
+
[now, prefs.settleAfter, stamps.startedAt, stamps.finishedAt],
|
|
192
206
|
);
|
|
193
207
|
// Rows and groups that did not change keep their objects, so their
|
|
194
208
|
// memoized components skip the render.
|
|
@@ -281,7 +295,7 @@ function ThreadListBody({
|
|
|
281
295
|
|
|
282
296
|
const runMenuAction = useCallback(
|
|
283
297
|
(action: RowMenuAction, thread: PluginSidebarThread, sectionId?: string | null) => {
|
|
284
|
-
const context = { activeThreadId, finishedAt: stamps.finishedAt, seenAt: stamps.seenAt };
|
|
298
|
+
const context = { activeThreadId, openThreadIds, finishedAt: stamps.finishedAt, seenAt: stamps.seenAt };
|
|
285
299
|
const fail = (message: string) => (error: unknown) => toast.error(message, { description: describeError(error) });
|
|
286
300
|
switch (action) {
|
|
287
301
|
case "open-in-split":
|
|
@@ -334,7 +348,7 @@ function ThreadListBody({
|
|
|
334
348
|
return;
|
|
335
349
|
}
|
|
336
350
|
},
|
|
337
|
-
[actions, activeThreadId, clearSeen, markSeen, onNavigate, sdk, stamps.finishedAt, stamps.seenAt],
|
|
351
|
+
[actions, activeThreadId, openThreadIds, clearSeen, markSeen, onNavigate, sdk, stamps.finishedAt, stamps.seenAt],
|
|
338
352
|
);
|
|
339
353
|
const runMenuActionRef = useRef(runMenuAction);
|
|
340
354
|
useLayoutEffect(() => {
|
|
@@ -343,9 +357,9 @@ function ThreadListBody({
|
|
|
343
357
|
|
|
344
358
|
// The committed state for the controllers' callbacks: they read it when
|
|
345
359
|
// called, so the controllers themselves stay put while threads change.
|
|
346
|
-
const latest = useRef({ forest, view, prefs, byId, activeThreadId, stamps });
|
|
360
|
+
const latest = useRef({ forest, view, prefs, byId, activeThreadId, openThreadIds, stamps });
|
|
347
361
|
useLayoutEffect(() => {
|
|
348
|
-
latest.current = { forest, view, prefs, byId, activeThreadId, stamps };
|
|
362
|
+
latest.current = { forest, view, prefs, byId, activeThreadId, openThreadIds, stamps };
|
|
349
363
|
});
|
|
350
364
|
|
|
351
365
|
const loadModel = useCallback(
|
|
@@ -459,15 +473,13 @@ function ThreadListBody({
|
|
|
459
473
|
/** Marks every unread thread in the trees read, asking first above MARK_ALL_CONFIRM_ABOVE. `where` names them. */
|
|
460
474
|
const markTreesRead = useCallback(
|
|
461
475
|
(trees: readonly ThreadTree[], where: string) => {
|
|
462
|
-
const { activeThreadId, stamps } = latest.current;
|
|
463
|
-
const plan = markAllReadPlan(trees, { activeThreadId, finishedAt: stamps.finishedAt, seenAt: stamps.seenAt });
|
|
476
|
+
const { activeThreadId, openThreadIds, stamps } = latest.current;
|
|
477
|
+
const plan = markAllReadPlan(trees, { activeThreadId, openThreadIds, finishedAt: stamps.finishedAt, seenAt: stamps.seenAt });
|
|
464
478
|
const run = () => {
|
|
465
479
|
if (plan.seen.length > 0) markSeen(plan.seen);
|
|
466
480
|
for (const id of plan.read) actions.setRead(id, true).catch(() => undefined);
|
|
467
481
|
};
|
|
468
|
-
if (plan.read.length
|
|
469
|
-
toast(`Nothing unread in ${where}`);
|
|
470
|
-
} else if (plan.read.length > MARK_ALL_CONFIRM_ABOVE) {
|
|
482
|
+
if (plan.read.length > MARK_ALL_CONFIRM_ABOVE) {
|
|
471
483
|
setConfirm({
|
|
472
484
|
title: `Mark ${plan.read.length} threads read?`,
|
|
473
485
|
description: `Every unread thread in ${where}, child threads included, will be marked read.`,
|
|
@@ -711,19 +723,19 @@ function ThreadListBody({
|
|
|
711
723
|
|
|
712
724
|
return (
|
|
713
725
|
<ListLiveContext.Provider value={live}>
|
|
714
|
-
<div className="flex w-full min-w-0 flex-col px-1.5 pb-2">
|
|
726
|
+
<div ref={root} className="flex w-full min-w-0 flex-col px-1.5 pb-2">
|
|
715
727
|
<ListHeader
|
|
716
728
|
mode={prefs.organizationMode}
|
|
717
729
|
needYouCount={view.needYouCount}
|
|
718
730
|
needYouOnly={needYouOn}
|
|
719
731
|
onToggleNeedYou={onToggleNeedYou}
|
|
732
|
+
hasUnread={view.hasUnread}
|
|
720
733
|
onMarkAllRead={onMarkListRead}
|
|
721
734
|
prefs={prefs}
|
|
722
735
|
client={client}
|
|
723
736
|
onPrefs={update}
|
|
724
737
|
onClient={updateClient}
|
|
725
738
|
/>
|
|
726
|
-
<PullRequestProbes threadIds={pullRequestLookups} onAnswer={onPullRequest} />
|
|
727
739
|
{threads.length === 0 ? (
|
|
728
740
|
// bb's own pinned New thread button covers the empty list.
|
|
729
741
|
<p className="px-3 py-4 text-sm text-muted-foreground">No threads yet.</p>
|
|
@@ -32,6 +32,7 @@ import { RowContextMenuContent, RowDropdownMenuContent, type ContextMenuInput }
|
|
|
32
32
|
import { SplitMiniMap, type MiniMapPane } from "./SplitMiniMap";
|
|
33
33
|
import { ThreadDetails } from "./ThreadDetails";
|
|
34
34
|
import { useRowCard } from "./row-card";
|
|
35
|
+
import { ROW_HOVER_HIDES, ROW_HOVER_LAYS_OUT, ROW_HOVER_SHOWS } from "./input-modality";
|
|
35
36
|
|
|
36
37
|
/** Two clicks on one row within this window start a rename, as in bb. */
|
|
37
38
|
const RENAME_CLICK_MS = 400;
|
|
@@ -88,7 +89,7 @@ export interface ThreadRowViewProps {
|
|
|
88
89
|
inPinned: boolean;
|
|
89
90
|
/** Drop feedback from the list's drag state. */
|
|
90
91
|
dropState: "valid" | "blocked" | "unchanged" | "before" | "after" | null;
|
|
91
|
-
/** The
|
|
92
|
+
/** The focused thread is this one. */
|
|
92
93
|
active: boolean;
|
|
93
94
|
/** Its title is being renamed. */
|
|
94
95
|
editing: boolean;
|
|
@@ -309,7 +310,7 @@ export const ThreadRowView = memo(function ThreadRowView({
|
|
|
309
310
|
? ""
|
|
310
311
|
: menuShowing
|
|
311
312
|
? "opacity-0"
|
|
312
|
-
:
|
|
313
|
+
: ROW_HOVER_HIDES;
|
|
313
314
|
|
|
314
315
|
const body = (
|
|
315
316
|
<div
|
|
@@ -471,7 +472,7 @@ export const ThreadRowView = memo(function ThreadRowView({
|
|
|
471
472
|
<span
|
|
472
473
|
className={cn(
|
|
473
474
|
"items-center justify-self-end gap-0.5 pl-1.5 [grid-area:1/1]",
|
|
474
|
-
menuShowing ? "flex" :
|
|
475
|
+
menuShowing ? "flex" : ROW_HOVER_LAYS_OUT,
|
|
475
476
|
)}
|
|
476
477
|
>
|
|
477
478
|
{row.treeUnread ? (
|
|
@@ -577,7 +578,7 @@ export const ThreadRowView = memo(function ThreadRowView({
|
|
|
577
578
|
? "relative"
|
|
578
579
|
: menuShowing
|
|
579
580
|
? "opacity-100"
|
|
580
|
-
:
|
|
581
|
+
: ROW_HOVER_SHOWS,
|
|
581
582
|
)}
|
|
582
583
|
>
|
|
583
584
|
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
|
@@ -54,7 +54,7 @@ export interface ListLive {
|
|
|
54
54
|
|
|
55
55
|
export const ListLiveContext = createContext<ListLive>({
|
|
56
56
|
now: 0,
|
|
57
|
-
stamps: { startedAt: {}, finishedAt: {}, pendingAt: {}, seenAt: {} },
|
|
57
|
+
stamps: { startedAt: {}, finishedAt: {}, pendingAt: {}, seenAt: {}, idleAt: {} },
|
|
58
58
|
notes: {},
|
|
59
59
|
treeOf: () => undefined,
|
|
60
60
|
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Which input moved focus last, keyboard or pointer, written on the list's
|
|
2
|
+
// root as `data-input-modality`. Rows and group headers show their hover
|
|
3
|
+
// actions for focus inside them only under `keyboard`, so a click that leaves
|
|
4
|
+
// focus in a row does not keep its hover look once the pointer leaves.
|
|
5
|
+
// `:focus-visible` says the same, but Chromium drops focus on Tab from a row's
|
|
6
|
+
// link when the actions it would reach are shown by `:has(:focus-visible)`.
|
|
7
|
+
import { useEffect, type RefObject } from "react";
|
|
8
|
+
|
|
9
|
+
export type InputModality = "keyboard" | "pointer";
|
|
10
|
+
|
|
11
|
+
/** The modality an event starts, or null for one that changes nothing. */
|
|
12
|
+
export function modalityOf(event: { type: string; key?: string; metaKey?: boolean; ctrlKey?: boolean; altKey?: boolean }): InputModality | null {
|
|
13
|
+
if (event.type === "pointerdown") return "pointer";
|
|
14
|
+
if (event.type !== "keydown") return null;
|
|
15
|
+
// A shortcut or a lone modifier does not move focus, as Chromium's own
|
|
16
|
+
// `:focus-visible` treats them.
|
|
17
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return null;
|
|
18
|
+
if (event.key === "Shift" || event.key === "Meta" || event.key === "Control" || event.key === "Alt") return null;
|
|
19
|
+
return "keyboard";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// What a row or group header shows while hovered or holding keyboard focus,
|
|
23
|
+
// as whole class strings so Tailwind finds each one in the source.
|
|
24
|
+
|
|
25
|
+
/** A row part that fades out on hover, for the actions that take its place. */
|
|
26
|
+
export const ROW_HOVER_HIDES = "group-hover/row:opacity-0 in-data-[input-modality=keyboard]:group-focus-within/row:opacity-0";
|
|
27
|
+
/** A row part that fades in on hover. */
|
|
28
|
+
export const ROW_HOVER_SHOWS = "opacity-0 group-hover/row:opacity-100 in-data-[input-modality=keyboard]:group-focus-within/row:opacity-100";
|
|
29
|
+
/** A row part laid out only on hover, so it takes no width at rest. */
|
|
30
|
+
export const ROW_HOVER_LAYS_OUT = "hidden group-hover/row:flex in-data-[input-modality=keyboard]:group-focus-within/row:flex";
|
|
31
|
+
/** A group header part that fades out on hover. */
|
|
32
|
+
export const HEADER_HOVER_HIDES = "group-hover/header:opacity-0 in-data-[input-modality=keyboard]:group-focus-within/header:opacity-0";
|
|
33
|
+
/** A group header part that fades in on hover, and takes the pointer only then. */
|
|
34
|
+
export const HEADER_HOVER_SHOWS =
|
|
35
|
+
"pointer-events-none opacity-0 group-hover/header:pointer-events-auto group-hover/header:opacity-100 in-data-[input-modality=keyboard]:group-focus-within/header:pointer-events-auto in-data-[input-modality=keyboard]:group-focus-within/header:opacity-100";
|
|
36
|
+
|
|
37
|
+
/** Keeps `data-input-modality` on the element current for the whole document. */
|
|
38
|
+
export function useInputModality(root: RefObject<HTMLElement | null>): void {
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const onInput = (event: Event) => {
|
|
41
|
+
const modality = modalityOf(event as KeyboardEvent);
|
|
42
|
+
if (modality !== null) root.current?.setAttribute("data-input-modality", modality);
|
|
43
|
+
};
|
|
44
|
+
document.addEventListener("keydown", onInput, true);
|
|
45
|
+
document.addEventListener("pointerdown", onInput, true);
|
|
46
|
+
return () => {
|
|
47
|
+
document.removeEventListener("keydown", onInput, true);
|
|
48
|
+
document.removeEventListener("pointerdown", onInput, true);
|
|
49
|
+
};
|
|
50
|
+
}, [root]);
|
|
51
|
+
}
|
|
@@ -80,7 +80,7 @@ export function useRowCard(isActive: boolean, enabled: boolean): RowCard {
|
|
|
80
80
|
};
|
|
81
81
|
}, []);
|
|
82
82
|
|
|
83
|
-
// Becoming or ceasing to be the
|
|
83
|
+
// Becoming or ceasing to be the focused thread, or a menu or rename taking
|
|
84
84
|
// the row, closes the card and drops a pending open.
|
|
85
85
|
useEffect(() => {
|
|
86
86
|
cancel();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// When each thread last went from busy to idle, as this list saw it: the
|
|
2
|
+
// orphaned-failure wait counts from it. Each change seen is also written to
|
|
3
|
+
// the server's `idleAt` stamp, so a reload or another window keeps it.
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
|
|
6
|
+
import { trackIdle, type IdleTracker } from "../model/attention";
|
|
7
|
+
|
|
8
|
+
const NONE: Readonly<Record<string, number>> = {};
|
|
9
|
+
|
|
10
|
+
export function useIdleSince(
|
|
11
|
+
threads: readonly PluginSidebarThread[],
|
|
12
|
+
markIdle: (threadIds: string[]) => void,
|
|
13
|
+
): Readonly<Record<string, number>> {
|
|
14
|
+
const [seen, setSeen] = useState<readonly PluginSidebarThread[] | null>(null);
|
|
15
|
+
const [tracker, setTracker] = useState<IdleTracker | null>(null);
|
|
16
|
+
// Adjusted during render, as React adjusts state from a changed input, so
|
|
17
|
+
// the render that sees the change already carries its time.
|
|
18
|
+
if (threads !== seen) {
|
|
19
|
+
const at = Date.now();
|
|
20
|
+
setSeen(threads);
|
|
21
|
+
setTracker((previous) => trackIdle(previous, threads, at));
|
|
22
|
+
}
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (tracker !== null && tracker.wentIdle.length > 0) markIdle([...tracker.wentIdle]);
|
|
25
|
+
}, [tracker, markIdle]);
|
|
26
|
+
return tracker?.idleSince ?? NONE;
|
|
27
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// A clock that ticks each minute (row ages) and at the next deadline
|
|
2
|
-
// (scheduled → queued), so rows change without
|
|
2
|
+
// (scheduled → queued, a failure becoming orphaned), so rows change without
|
|
3
|
+
// a request. A deadline already past ticks at once.
|
|
3
4
|
import { useEffect, useState } from "react";
|
|
4
5
|
|
|
5
6
|
export function useNow(nextDeadline: number | null): number {
|
|
@@ -11,8 +12,8 @@ export function useNow(nextDeadline: number | null): number {
|
|
|
11
12
|
useEffect(() => {
|
|
12
13
|
if (nextDeadline === null) return;
|
|
13
14
|
const wait = nextDeadline - Date.now();
|
|
14
|
-
if (wait
|
|
15
|
-
const timeout = setTimeout(() => setNow(Date.now()), wait + 50);
|
|
15
|
+
if (wait > 2 ** 31 - 1) return;
|
|
16
|
+
const timeout = setTimeout(() => setNow(Date.now()), Math.max(wait, 0) + 50);
|
|
16
17
|
return () => clearTimeout(timeout);
|
|
17
18
|
}, [nextDeadline]);
|
|
18
19
|
return now;
|
|
@@ -4,25 +4,33 @@ import { useRealtime, useRealtimeConnectionState, useRpc } from "@get-bb/plugin-
|
|
|
4
4
|
import type { RpcContract } from "@/shared/contract";
|
|
5
5
|
import { CHANNELS, type StampSignal, type Stamps } from "@/shared/signals";
|
|
6
6
|
|
|
7
|
-
const EMPTY: Stamps = { startedAt: {}, finishedAt: {}, pendingAt: {}, seenAt: {} };
|
|
7
|
+
const EMPTY: Stamps = { startedAt: {}, finishedAt: {}, pendingAt: {}, seenAt: {}, idleAt: {} };
|
|
8
8
|
const KINDS = new Set(Object.keys(EMPTY));
|
|
9
9
|
|
|
10
10
|
export interface StampsState {
|
|
11
11
|
stamps: Stamps;
|
|
12
|
+
/** The first listing came back, or failed: until then every map is empty for want of it. */
|
|
13
|
+
loaded: boolean;
|
|
12
14
|
markSeen(threadIds: string[]): void;
|
|
15
|
+
/** Records on the server that these threads were just seen going idle. */
|
|
16
|
+
markIdle(threadIds: string[]): void;
|
|
13
17
|
clearSeen(threadIds: string[]): void;
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
export function useStamps(): StampsState {
|
|
17
21
|
const rpc = useRpc<RpcContract>();
|
|
18
22
|
const [stamps, setStamps] = useState<Stamps>(EMPTY);
|
|
23
|
+
const [loaded, setLoaded] = useState(false);
|
|
19
24
|
const connection = useRealtimeConnectionState();
|
|
20
25
|
const wasConnected = useRef(false);
|
|
21
26
|
|
|
22
27
|
const load = useCallback(() => {
|
|
23
28
|
rpc.call("listStamps", null).then(
|
|
24
|
-
(result) =>
|
|
25
|
-
|
|
29
|
+
(result) => {
|
|
30
|
+
setStamps(result.stamps);
|
|
31
|
+
setLoaded(true);
|
|
32
|
+
},
|
|
33
|
+
() => setLoaded(true),
|
|
26
34
|
);
|
|
27
35
|
}, [rpc]);
|
|
28
36
|
|
|
@@ -55,6 +63,13 @@ export function useStamps(): StampsState {
|
|
|
55
63
|
},
|
|
56
64
|
[apply, rpc],
|
|
57
65
|
);
|
|
66
|
+
const markIdle = useCallback(
|
|
67
|
+
(threadIds: string[]) => {
|
|
68
|
+
if (threadIds.length === 0) return;
|
|
69
|
+
rpc.call("markIdle", { threadIds }).catch(() => undefined);
|
|
70
|
+
},
|
|
71
|
+
[rpc],
|
|
72
|
+
);
|
|
58
73
|
const clearSeen = useCallback(
|
|
59
74
|
(threadIds: string[]) => {
|
|
60
75
|
if (threadIds.length === 0) return;
|
|
@@ -64,5 +79,5 @@ export function useStamps(): StampsState {
|
|
|
64
79
|
[apply, rpc],
|
|
65
80
|
);
|
|
66
81
|
|
|
67
|
-
return { stamps, markSeen, clearSeen };
|
|
82
|
+
return { stamps, loaded, markSeen, markIdle, clearSeen };
|
|
68
83
|
}
|
|
@@ -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 Flag, type StateKind, type ThreadState } from "./state";
|
|
6
|
+
import { computeState, type Flag, type StateKind, type ThreadState } from "./state";
|
|
7
7
|
|
|
8
8
|
/** The flags that count for a root thread. Working is not one. */
|
|
9
9
|
export const ROOT_ATTENTION: ReadonlySet<Flag> = new Set<Flag>([
|
|
@@ -17,11 +17,24 @@ export const ROOT_ATTENTION: ReadonlySet<Flag> = new Set<Flag>([
|
|
|
17
17
|
/** States in which a parent thread is busy and may still deal with a failed child. */
|
|
18
18
|
const BUSY: ReadonlySet<StateKind> = new Set<StateKind>(["working", "background", "queued", "scheduled"]);
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* How long a parent thread stays idle before a child's failure counts as
|
|
22
|
+
* orphaned. bb reports a child's turn to its parent after a 2 s batch delay,
|
|
23
|
+
* and the parent then has to start: a failure it is about to pick up never
|
|
24
|
+
* reaches the need-you filter.
|
|
25
|
+
*/
|
|
26
|
+
export const ORPHAN_WAIT_MS = 5_000;
|
|
27
|
+
|
|
20
28
|
export interface ParentThread {
|
|
21
29
|
thread: Pick<PluginSidebarThread, "latestAttentionAt">;
|
|
22
30
|
state: Pick<ThreadState, "kind">;
|
|
23
31
|
/** When the plugin server last saw the parent thread finish a turn, if it did. */
|
|
24
32
|
finishedAt?: number;
|
|
33
|
+
/**
|
|
34
|
+
* When a window last saw the parent thread go from busy to idle, if one did;
|
|
35
|
+
* null while that is not yet known, and no failure under it counts.
|
|
36
|
+
*/
|
|
37
|
+
idleSince?: number | null;
|
|
25
38
|
}
|
|
26
39
|
|
|
27
40
|
/** A parent thread is idle when it is not working, setting up, running background work or holding a queued message. */
|
|
@@ -41,21 +54,72 @@ export function failureTime(
|
|
|
41
54
|
}
|
|
42
55
|
|
|
43
56
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
57
|
+
* When a child's failure becomes an orphaned failure, or null while it cannot:
|
|
58
|
+
* the parent thread is idle and has not run or finished since the child
|
|
59
|
+
* failed, and has stayed idle for `ORPHAN_WAIT_MS` from the later of the
|
|
60
|
+
* failure and its last becoming idle. A busy parent thread, or one that
|
|
46
61
|
* finished after the failure, is taken to know about it. The parent thread's
|
|
47
62
|
* own clock is its last finish (`finishedAt`, or `latestAttentionAt` when it
|
|
48
63
|
* failed): `updatedAt` also moves when someone merely opens or renames it.
|
|
49
64
|
*/
|
|
65
|
+
export function orphanedAt(
|
|
66
|
+
thread: Pick<PluginSidebarThread, "latestAttentionAt" | "updatedAt">,
|
|
67
|
+
flags: ReadonlySet<Flag>,
|
|
68
|
+
parent: ParentThread,
|
|
69
|
+
): number | null {
|
|
70
|
+
if (!flags.has("unread-failed") && !flags.has("queue-failed")) return null;
|
|
71
|
+
if (!isParentIdle(parent) || parent.idleSince === null) return null;
|
|
72
|
+
const parentActiveAt = Math.max(parent.thread.latestAttentionAt, parent.finishedAt ?? 0);
|
|
73
|
+
const failedAt = failureTime(thread, flags);
|
|
74
|
+
if (parentActiveAt > failedAt) return null;
|
|
75
|
+
return Math.max(failedAt, parent.idleSince ?? 0) + ORPHAN_WAIT_MS;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Whether a child's failure is an orphaned failure at `now` (see `orphanedAt`). */
|
|
50
79
|
export function isOrphanedFailure(
|
|
51
80
|
thread: Pick<PluginSidebarThread, "latestAttentionAt" | "updatedAt">,
|
|
52
81
|
flags: ReadonlySet<Flag>,
|
|
53
82
|
parent: ParentThread,
|
|
83
|
+
now: number,
|
|
54
84
|
): boolean {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
85
|
+
const at = orphanedAt(thread, flags, parent);
|
|
86
|
+
return at !== null && at <= now;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Which threads the list last saw busy, and when each last went from busy to idle. */
|
|
90
|
+
export interface IdleTracker {
|
|
91
|
+
busy: ReadonlySet<string>;
|
|
92
|
+
idleSince: Readonly<Record<string, number>>;
|
|
93
|
+
/** The threads this step saw go idle, for the server's `idleAt`. */
|
|
94
|
+
wentIdle: readonly string[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isBusyThread(thread: PluginSidebarThread): boolean {
|
|
98
|
+
const state = computeState(thread, { unread: false, hasDraft: false, scheduledAt: null, now: 0 });
|
|
99
|
+
return !isParentIdle({ thread, state });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The tracker after the list sees `threads` at `at`: a thread busy before and
|
|
104
|
+
* idle now went idle at `at`. A thread first seen idle has no time here; the
|
|
105
|
+
* server's `idleAt` holds what an earlier window saw.
|
|
106
|
+
*/
|
|
107
|
+
export function trackIdle(previous: IdleTracker | null, threads: readonly PluginSidebarThread[], at: number): IdleTracker {
|
|
108
|
+
const busy = new Set<string>();
|
|
109
|
+
const idleSince: Record<string, number> = {};
|
|
110
|
+
const wentIdle: string[] = [];
|
|
111
|
+
for (const thread of threads) {
|
|
112
|
+
const id = thread.id;
|
|
113
|
+
if (isBusyThread(thread)) {
|
|
114
|
+
busy.add(id);
|
|
115
|
+
} else if (previous?.busy.has(id)) {
|
|
116
|
+
idleSince[id] = at;
|
|
117
|
+
wentIdle.push(id);
|
|
118
|
+
} else if (previous?.idleSince[id] !== undefined) {
|
|
119
|
+
idleSince[id] = previous.idleSince[id];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { busy, idleSince, wentIdle };
|
|
59
123
|
}
|
|
60
124
|
|
|
61
125
|
/**
|
|
@@ -6,7 +6,7 @@ import type { Forest } from "./trees";
|
|
|
6
6
|
/**
|
|
7
7
|
* `reveal` targets open the parent chips on their path. `open` targets (a
|
|
8
8
|
* thread that newly became unread) open no chip, so finished children don't
|
|
9
|
-
* expand their parent's. The
|
|
9
|
+
* expand their parent's. The focused thread's target, of either kind, also
|
|
10
10
|
* opens its collapsed group. No target opens a settled fold.
|
|
11
11
|
*/
|
|
12
12
|
export type TargetKind = "reveal" | "open";
|