@gperezmz/bb-plugin-thread-glance 0.4.1 → 0.5.0
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 +30 -30
- 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/FoldRows.tsx +3 -3
- package/features/thread-list/components/GroupSection.tsx +10 -7
- package/features/thread-list/components/ListHeader.tsx +9 -4
- package/features/thread-list/components/ProviderBadge.tsx +1 -1
- package/features/thread-list/components/PullRequestBadge.tsx +1 -1
- package/features/thread-list/components/ThreadList.tsx +34 -13
- package/features/thread-list/components/ThreadRowView.tsx +9 -10
- package/features/thread-list/components/controller.ts +1 -1
- package/features/thread-list/components/glyphs.tsx +7 -4
- 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 +1 -1
- package/features/thread-list/model/state.ts +18 -5
- 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 +11 -5
- package/features/thread-list/testing/browser.css +7 -0
- package/features/thread-list/testing/fixtures.ts +10 -0
- 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 +1 -1
package/dist/server.meta.json
CHANGED
|
@@ -39,7 +39,7 @@ export const OlderRowView = memo(function OlderRowView({ row, controller }: { ro
|
|
|
39
39
|
);
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
/** The settled fold: a
|
|
42
|
+
/** The settled fold: a muted label, a hairline to the row's end, and a chevron. */
|
|
43
43
|
export const SettledRowView = memo(function SettledRowView({ row, controller }: { row: SettledRow; controller: RowController }) {
|
|
44
44
|
const { label, ariaLabel } = settledRowText(row);
|
|
45
45
|
return (
|
|
@@ -48,7 +48,7 @@ export const SettledRowView = memo(function SettledRowView({ row, controller }:
|
|
|
48
48
|
aria-expanded={row.expanded}
|
|
49
49
|
aria-label={ariaLabel}
|
|
50
50
|
onClick={() => controller.onToggleSettled(row)}
|
|
51
|
-
className="group/settled relative flex h-6 w-full items-center gap-2 rounded-md pr-1 text-left text-
|
|
51
|
+
className="group/settled relative flex h-6 w-full items-center gap-2 rounded-md pr-1 text-left text-xs text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring max-md:pointer-coarse:h-9"
|
|
52
52
|
style={{ paddingLeft: rowIndent(0) }}
|
|
53
53
|
>
|
|
54
54
|
<span className="shrink-0 tabular-nums">{label}</span>
|
|
@@ -102,7 +102,7 @@ export const EnvironmentRowView = memo(function EnvironmentRowView({
|
|
|
102
102
|
<span className="pointer-events-none relative min-w-0 flex-1 truncate">{row.label}</span>
|
|
103
103
|
)}
|
|
104
104
|
{row.flag !== null ? <FlagGlyph flag={row.flag} className="pointer-events-none relative size-3.5" /> : null}
|
|
105
|
-
<span className="pointer-events-none relative text-
|
|
105
|
+
<span className="pointer-events-none relative text-xs tabular-nums">{row.threadIds.length}</span>
|
|
106
106
|
<Icon
|
|
107
107
|
name={ICONS.expand}
|
|
108
108
|
aria-hidden
|
|
@@ -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,
|
|
@@ -54,16 +57,18 @@ export const ListHeader = memo(function ListHeader({
|
|
|
54
57
|
title={needYouOnly ? "Show every thread" : "Show only what needs you"}
|
|
55
58
|
onClick={onToggleNeedYou}
|
|
56
59
|
className={cn(
|
|
57
|
-
"inline-flex h-5 shrink-0 items-center rounded-full px-1.5 text-
|
|
60
|
+
"inline-flex h-5 shrink-0 items-center rounded-full px-1.5 text-xs leading-none tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
|
58
61
|
needYouOnly ? NEED_YOU_ON : NEED_YOU,
|
|
59
62
|
)}
|
|
60
63
|
>
|
|
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}>
|
|
@@ -45,7 +45,7 @@ export function ProviderBadge({
|
|
|
45
45
|
aria-label={display.name}
|
|
46
46
|
title={display.name}
|
|
47
47
|
className={cn(
|
|
48
|
-
"inline-flex h-3.5 min-w-3.5 shrink-0 items-center justify-center rounded-[3px] border border-border px-px text-[
|
|
48
|
+
"inline-flex h-3.5 min-w-3.5 shrink-0 items-center justify-center rounded-[3px] border border-border px-px text-[10px] font-semibold leading-none tracking-tight text-muted-foreground",
|
|
49
49
|
muted && "opacity-60",
|
|
50
50
|
className,
|
|
51
51
|
)}
|
|
@@ -42,7 +42,7 @@ export function PullRequestBadge({ threadId }: { threadId: string }) {
|
|
|
42
42
|
<span
|
|
43
43
|
title={pullRequestLabel(pullRequest)}
|
|
44
44
|
aria-label={pullRequestLabel(pullRequest)}
|
|
45
|
-
className={cn("shrink-0 text-
|
|
45
|
+
className={cn("shrink-0 text-xs tabular-nums", pullRequestTone(pullRequest.attention))}
|
|
46
46
|
>
|
|
47
47
|
#{pullRequest.number}
|
|
48
48
|
</span>
|
|
@@ -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";
|
|
@@ -52,6 +54,7 @@ import { modelDisplayName } from "../model/details";
|
|
|
52
54
|
import { groupIdForRoot } from "../model/groups";
|
|
53
55
|
import { CounterStrip } from "./glyphs";
|
|
54
56
|
import { cancelPendingCards } from "./row-card";
|
|
57
|
+
import { useInputModality } from "./input-modality";
|
|
55
58
|
import { GroupSection, type DropStates, type GroupController } from "./GroupSection";
|
|
56
59
|
import type { ProviderDisplay } from "./ProviderBadge";
|
|
57
60
|
import { ThreadDetails } from "./ThreadDetails";
|
|
@@ -113,19 +116,31 @@ function ThreadListBody({
|
|
|
113
116
|
const [client, updateClient] = useClientPreferences();
|
|
114
117
|
// The need-you filter is per window and starts off on every load.
|
|
115
118
|
const [needYouOn, setNeedYouOn] = useState(false);
|
|
119
|
+
const root = useRef<HTMLDivElement>(null);
|
|
120
|
+
useInputModality(root);
|
|
116
121
|
const sidebar = useSidebarThreads({ experimental_lifecycles: prefs.showArchived ? ["active", "archived"] : ["active"] });
|
|
117
122
|
const actions = useThreadActions();
|
|
118
123
|
const sdk = useSdk();
|
|
119
124
|
const { providers } = useProviders();
|
|
120
125
|
const { providers: environmentProviders } = useEnvironmentProviders();
|
|
121
126
|
const draftIds = useSidebarThreadDraftIds();
|
|
122
|
-
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();
|
|
123
134
|
const scheduled = useScheduled();
|
|
124
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);
|
|
125
139
|
const nextDeadline = useMemo(() => {
|
|
126
140
|
const future = Object.values(scheduled).filter((at) => at > Date.now());
|
|
141
|
+
if (orphanDeadline !== null) future.push(orphanDeadline);
|
|
127
142
|
return future.length > 0 ? Math.min(...future) : null;
|
|
128
|
-
}, [scheduled]);
|
|
143
|
+
}, [scheduled, orphanDeadline]);
|
|
129
144
|
const now = useNow(nextDeadline);
|
|
130
145
|
|
|
131
146
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
@@ -143,6 +158,7 @@ function ThreadListBody({
|
|
|
143
158
|
const ready = sidebar.status === "ready";
|
|
144
159
|
const threads = sidebar.threads;
|
|
145
160
|
const byId = useMemo(() => new Map(threads.map((thread) => [thread.id, thread])), [threads]);
|
|
161
|
+
const idleSince = useIdleSince(threads, markIdle);
|
|
146
162
|
|
|
147
163
|
const forest = useMemo(
|
|
148
164
|
() =>
|
|
@@ -150,6 +166,7 @@ function ThreadListBody({
|
|
|
150
166
|
? buildForest({
|
|
151
167
|
threads,
|
|
152
168
|
activeThreadId,
|
|
169
|
+
openThreadIds,
|
|
153
170
|
finishedAt: stamps.finishedAt,
|
|
154
171
|
seenAt: stamps.seenAt,
|
|
155
172
|
draftIds,
|
|
@@ -157,10 +174,15 @@ function ThreadListBody({
|
|
|
157
174
|
now,
|
|
158
175
|
notes,
|
|
159
176
|
childAttention: prefs.childAttention,
|
|
177
|
+
idleSince,
|
|
178
|
+
idleAt: stamps.idleAt,
|
|
179
|
+
stampsLoaded,
|
|
160
180
|
})
|
|
161
181
|
: null,
|
|
162
|
-
[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],
|
|
163
183
|
);
|
|
184
|
+
const nextOrphanAt = forest?.nextOrphanAt ?? null;
|
|
185
|
+
if (nextOrphanAt !== orphanDeadline) setOrphanDeadline(nextOrphanAt);
|
|
164
186
|
const { targets, prune } = useAutoExpand(hydrated ? forest : null, activeThreadId);
|
|
165
187
|
// Projects with a thread on a branch: the rest need no default branch.
|
|
166
188
|
const branchedProjectIds = useMemo(
|
|
@@ -273,7 +295,7 @@ function ThreadListBody({
|
|
|
273
295
|
|
|
274
296
|
const runMenuAction = useCallback(
|
|
275
297
|
(action: RowMenuAction, thread: PluginSidebarThread, sectionId?: string | null) => {
|
|
276
|
-
const context = { activeThreadId, finishedAt: stamps.finishedAt, seenAt: stamps.seenAt };
|
|
298
|
+
const context = { activeThreadId, openThreadIds, finishedAt: stamps.finishedAt, seenAt: stamps.seenAt };
|
|
277
299
|
const fail = (message: string) => (error: unknown) => toast.error(message, { description: describeError(error) });
|
|
278
300
|
switch (action) {
|
|
279
301
|
case "open-in-split":
|
|
@@ -326,7 +348,7 @@ function ThreadListBody({
|
|
|
326
348
|
return;
|
|
327
349
|
}
|
|
328
350
|
},
|
|
329
|
-
[actions, activeThreadId, clearSeen, markSeen, onNavigate, sdk, stamps.finishedAt, stamps.seenAt],
|
|
351
|
+
[actions, activeThreadId, openThreadIds, clearSeen, markSeen, onNavigate, sdk, stamps.finishedAt, stamps.seenAt],
|
|
330
352
|
);
|
|
331
353
|
const runMenuActionRef = useRef(runMenuAction);
|
|
332
354
|
useLayoutEffect(() => {
|
|
@@ -335,9 +357,9 @@ function ThreadListBody({
|
|
|
335
357
|
|
|
336
358
|
// The committed state for the controllers' callbacks: they read it when
|
|
337
359
|
// called, so the controllers themselves stay put while threads change.
|
|
338
|
-
const latest = useRef({ forest, view, prefs, byId, activeThreadId, stamps });
|
|
360
|
+
const latest = useRef({ forest, view, prefs, byId, activeThreadId, openThreadIds, stamps });
|
|
339
361
|
useLayoutEffect(() => {
|
|
340
|
-
latest.current = { forest, view, prefs, byId, activeThreadId, stamps };
|
|
362
|
+
latest.current = { forest, view, prefs, byId, activeThreadId, openThreadIds, stamps };
|
|
341
363
|
});
|
|
342
364
|
|
|
343
365
|
const loadModel = useCallback(
|
|
@@ -451,15 +473,13 @@ function ThreadListBody({
|
|
|
451
473
|
/** Marks every unread thread in the trees read, asking first above MARK_ALL_CONFIRM_ABOVE. `where` names them. */
|
|
452
474
|
const markTreesRead = useCallback(
|
|
453
475
|
(trees: readonly ThreadTree[], where: string) => {
|
|
454
|
-
const { activeThreadId, stamps } = latest.current;
|
|
455
|
-
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 });
|
|
456
478
|
const run = () => {
|
|
457
479
|
if (plan.seen.length > 0) markSeen(plan.seen);
|
|
458
480
|
for (const id of plan.read) actions.setRead(id, true).catch(() => undefined);
|
|
459
481
|
};
|
|
460
|
-
if (plan.read.length
|
|
461
|
-
toast(`Nothing unread in ${where}`);
|
|
462
|
-
} else if (plan.read.length > MARK_ALL_CONFIRM_ABOVE) {
|
|
482
|
+
if (plan.read.length > MARK_ALL_CONFIRM_ABOVE) {
|
|
463
483
|
setConfirm({
|
|
464
484
|
title: `Mark ${plan.read.length} threads read?`,
|
|
465
485
|
description: `Every unread thread in ${where}, child threads included, will be marked read.`,
|
|
@@ -703,12 +723,13 @@ function ThreadListBody({
|
|
|
703
723
|
|
|
704
724
|
return (
|
|
705
725
|
<ListLiveContext.Provider value={live}>
|
|
706
|
-
<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">
|
|
707
727
|
<ListHeader
|
|
708
728
|
mode={prefs.organizationMode}
|
|
709
729
|
needYouCount={view.needYouCount}
|
|
710
730
|
needYouOnly={needYouOn}
|
|
711
731
|
onToggleNeedYou={onToggleNeedYou}
|
|
732
|
+
hasUnread={view.hasUnread}
|
|
712
733
|
onMarkAllRead={onMarkListRead}
|
|
713
734
|
prefs={prefs}
|
|
714
735
|
client={client}
|
|
@@ -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
|
|
@@ -412,8 +413,6 @@ export const ThreadRowView = memo(function ThreadRowView({
|
|
|
412
413
|
className={cn(
|
|
413
414
|
"min-w-0 truncate",
|
|
414
415
|
row.bold ? "font-semibold" : "font-normal",
|
|
415
|
-
// Children sit a step below their parent.
|
|
416
|
-
row.depth > 0 && "text-xs",
|
|
417
416
|
// Quiet threads step back so live ones lead; hover brings them back.
|
|
418
417
|
dimmed && `${QUIET_TEXT} group-hover/row:text-foreground`,
|
|
419
418
|
)}
|
|
@@ -460,7 +459,7 @@ export const ThreadRowView = memo(function ThreadRowView({
|
|
|
460
459
|
<span
|
|
461
460
|
title={`On ${row.machine}`}
|
|
462
461
|
aria-label={`On ${row.machine}`}
|
|
463
|
-
className="pointer-events-none max-w-20 truncate text-
|
|
462
|
+
className="pointer-events-none max-w-20 truncate text-xs text-muted-foreground"
|
|
464
463
|
>
|
|
465
464
|
{row.machine}
|
|
466
465
|
</span>
|
|
@@ -471,7 +470,7 @@ export const ThreadRowView = memo(function ThreadRowView({
|
|
|
471
470
|
<span
|
|
472
471
|
className={cn(
|
|
473
472
|
"items-center justify-self-end gap-0.5 pl-1.5 [grid-area:1/1]",
|
|
474
|
-
menuShowing ? "flex" :
|
|
473
|
+
menuShowing ? "flex" : ROW_HOVER_LAYS_OUT,
|
|
475
474
|
)}
|
|
476
475
|
>
|
|
477
476
|
{row.treeUnread ? (
|
|
@@ -526,7 +525,7 @@ export const ThreadRowView = memo(function ThreadRowView({
|
|
|
526
525
|
onPointerDown={(event) => event.stopPropagation()}
|
|
527
526
|
onKeyDown={(event) => event.stopPropagation()}
|
|
528
527
|
className={cn(
|
|
529
|
-
"pointer-events-auto relative z-10 inline-flex h-5 shrink-0 items-center gap-0.5 rounded-md px-0.5 text-
|
|
528
|
+
"pointer-events-auto relative z-10 inline-flex h-5 shrink-0 items-center gap-0.5 rounded-md px-0.5 text-xs leading-none tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
|
530
529
|
chip.flag === null
|
|
531
530
|
? cn("text-muted-foreground hover:text-foreground", dimmed && QUIET_TEXT)
|
|
532
531
|
: TONE_CLASS[chipTone(chip.flag)],
|
|
@@ -546,7 +545,7 @@ export const ThreadRowView = memo(function ThreadRowView({
|
|
|
546
545
|
<span data-trailing-slot="" className="relative grid h-6 shrink-0 items-center">
|
|
547
546
|
<span aria-hidden className="invisible w-6 [grid-area:1/1]" />
|
|
548
547
|
{TRAILING_SLOT_SIZERS.map((text) => (
|
|
549
|
-
<span key={text} aria-hidden className="invisible text-
|
|
548
|
+
<span key={text} aria-hidden className="invisible text-xs font-medium tabular-nums [grid-area:1/1]">
|
|
550
549
|
{text}
|
|
551
550
|
</span>
|
|
552
551
|
))}
|
|
@@ -561,7 +560,7 @@ export const ThreadRowView = memo(function ThreadRowView({
|
|
|
561
560
|
title={time.label}
|
|
562
561
|
aria-label={time.label}
|
|
563
562
|
className={cn(
|
|
564
|
-
"pointer-events-none justify-self-end text-
|
|
563
|
+
"pointer-events-none justify-self-end text-xs tabular-nums transition-opacity [grid-area:1/1]",
|
|
565
564
|
// A running timer reads as work, an age as history.
|
|
566
565
|
time.kind === "timer" ? "font-medium text-[var(--timeline-accent)]" : "text-muted-foreground",
|
|
567
566
|
fadeClass,
|
|
@@ -577,7 +576,7 @@ export const ThreadRowView = memo(function ThreadRowView({
|
|
|
577
576
|
? "relative"
|
|
578
577
|
: menuShowing
|
|
579
578
|
? "opacity-100"
|
|
580
|
-
:
|
|
579
|
+
: ROW_HOVER_SHOWS,
|
|
581
580
|
)}
|
|
582
581
|
>
|
|
583
582
|
<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
|
});
|
|
@@ -13,7 +13,9 @@ export const TONE_CLASS: Record<Tone, string> = {
|
|
|
13
13
|
destructive: "text-destructive",
|
|
14
14
|
// bb's timeline accent: blue, readable on the sidebar in both themes.
|
|
15
15
|
working: "text-[var(--timeline-accent)]",
|
|
16
|
-
|
|
16
|
+
// Full muted: bb's shine dims the glyph by about a third, and at 75% its
|
|
17
|
+
// dim frames fell below 3:1 against the light sidebar.
|
|
18
|
+
background: "text-muted-foreground",
|
|
17
19
|
"muted-strong": "text-muted-foreground/75",
|
|
18
20
|
muted: "text-muted-foreground",
|
|
19
21
|
none: "",
|
|
@@ -36,13 +38,14 @@ export function UnreadDot({ label, className }: { label?: string; className?: st
|
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
// The Idle state's ring: uncoloured, smaller than the column and thinned, so
|
|
39
|
-
// it reads as status yet stays fainter than
|
|
40
|
-
//
|
|
41
|
+
// it reads as status yet stays fainter than every other glyph. Its tone alone
|
|
42
|
+
// sets its colour, so it keeps 3:1 against the sidebar. Screen readers skip
|
|
43
|
+
// it, so an Idle row announces what it did before.
|
|
41
44
|
export function IdleRing({ className }: { className?: string }) {
|
|
42
45
|
return (
|
|
43
46
|
<span
|
|
44
47
|
aria-hidden
|
|
45
|
-
className={cn("inline-block size-2.5 shrink-0 rounded-full border-[1.5px] border-current
|
|
48
|
+
className={cn("inline-block size-2.5 shrink-0 rounded-full border-[1.5px] border-current", className)}
|
|
46
49
|
/>
|
|
47
50
|
);
|
|
48
51
|
}
|
|
@@ -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
|
}
|