@gperezmz/bb-plugin-thread-glance 0.1.3 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/app.css +1 -1
- package/dist/app.js +32 -32
- package/dist/app.meta.json +1 -1
- package/dist/server.js +8 -8
- package/dist/server.js.map +3 -3
- package/dist/server.meta.json +1 -1
- package/features/thread-list/components/FoldRows.tsx +14 -13
- package/features/thread-list/components/GroupSection.tsx +118 -69
- package/features/thread-list/components/Rails.tsx +2 -3
- package/features/thread-list/components/ThreadList.tsx +21 -16
- package/features/thread-list/components/ThreadRowView.tsx +12 -9
- package/features/thread-list/components/Toolbar.tsx +129 -187
- package/features/thread-list/components/controller.ts +1 -2
- package/features/thread-list/components/glyphs.tsx +1 -1
- package/features/thread-list/data/useClientPreferences.ts +1 -1
- package/features/thread-list/data/useNeedsYouHold.ts +19 -0
- package/features/thread-list/model/counters.ts +13 -19
- package/features/thread-list/model/details.ts +2 -2
- package/features/thread-list/model/expansion.ts +10 -10
- package/features/thread-list/model/families.ts +50 -48
- package/features/thread-list/model/labels.ts +2 -1
- package/features/thread-list/model/layout.ts +6 -11
- package/features/thread-list/model/needs-you.ts +133 -0
- package/features/thread-list/model/notes.ts +1 -1
- package/features/thread-list/model/settings.ts +54 -0
- package/features/thread-list/model/share.ts +7 -1
- package/features/thread-list/model/sort.ts +14 -6
- package/features/thread-list/model/state.ts +11 -11
- package/features/thread-list/model/time.ts +1 -1
- package/features/thread-list/model/toggles.ts +2 -7
- package/features/thread-list/model/view.ts +139 -143
- package/features/thread-list/testing/fixtures.ts +19 -10
- package/package.json +1 -1
- package/server/cli.ts +2 -2
- package/shared/preferences.ts +10 -23
- package/skills/thread-glance/SKILL.md +9 -10
- package/features/thread-list/model/attention.ts +0 -89
|
@@ -1,29 +1,20 @@
|
|
|
1
|
-
// The top of the scroll area: the
|
|
2
|
-
// popover.
|
|
3
|
-
import { useState } from "react";
|
|
1
|
+
// The top of the scroll area: the settings button and its popover.
|
|
4
2
|
import { experimental_Icon as Icon } from "@get-bb/plugin-sdk/app";
|
|
5
3
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
6
|
-
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
7
4
|
import { cn } from "@/lib/utils";
|
|
8
|
-
import type { ClientPreferences,
|
|
9
|
-
import {
|
|
5
|
+
import type { ClientPreferences, Preferences } from "@/shared/preferences";
|
|
6
|
+
import { childAttentionFor, countsEveryChild, lifecyclesFor, sortArrow, sortFieldPatch, threadsShown } from "../model/settings";
|
|
7
|
+
import { effectiveSortField } from "../model/sort";
|
|
10
8
|
import { ICONS } from "../icons";
|
|
9
|
+
import { TOOLBAR_HEIGHT } from "./GroupSection";
|
|
11
10
|
import { ROW_ICON_BUTTON } from "./ThreadRowView";
|
|
12
11
|
|
|
13
|
-
type Page = "organize" | "sort" | "display" | "filter";
|
|
14
|
-
|
|
15
|
-
const PAGES: readonly { id: Page; label: string }[] = [
|
|
16
|
-
{ id: "organize", label: "Organize" },
|
|
17
|
-
{ id: "sort", label: "Sort" },
|
|
18
|
-
{ id: "display", label: "Display" },
|
|
19
|
-
{ id: "filter", label: "Filter" },
|
|
20
|
-
];
|
|
21
|
-
|
|
22
12
|
// bb's own segmented controls (the Reasoning picker, the diff view toggle)
|
|
23
13
|
// mark the chosen item with the state-active token and no shadow. The track
|
|
24
14
|
// is a translucent tint, so the chosen item reads as raised in both themes.
|
|
25
|
-
const SEGMENT_TRACK = "flex gap-0.5 rounded-md bg-surface-recessed p-0.5";
|
|
26
|
-
const SEGMENT_ITEM =
|
|
15
|
+
const SEGMENT_TRACK = "flex min-w-0 flex-1 gap-0.5 rounded-md bg-surface-recessed p-0.5";
|
|
16
|
+
const SEGMENT_ITEM =
|
|
17
|
+
"flex min-w-0 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";
|
|
27
18
|
|
|
28
19
|
function segmentClass(selected: boolean): string {
|
|
29
20
|
return cn(
|
|
@@ -34,36 +25,42 @@ function segmentClass(selected: boolean): string {
|
|
|
34
25
|
);
|
|
35
26
|
}
|
|
36
27
|
|
|
37
|
-
|
|
28
|
+
/** A label, then its control, on one line. */
|
|
29
|
+
function Line({ label, children }: { label: string; children: React.ReactNode }) {
|
|
30
|
+
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}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function Segmented<T extends string>({
|
|
38
39
|
label,
|
|
39
|
-
|
|
40
|
+
options,
|
|
40
41
|
current,
|
|
41
42
|
onSelect,
|
|
42
|
-
description,
|
|
43
43
|
}: {
|
|
44
44
|
label: string;
|
|
45
|
-
|
|
45
|
+
options: readonly (readonly [T, string])[];
|
|
46
46
|
current: T;
|
|
47
47
|
onSelect(value: T): void;
|
|
48
|
-
description?: string;
|
|
49
48
|
}) {
|
|
50
|
-
const checked = value === current;
|
|
51
49
|
return (
|
|
52
|
-
<
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
</button>
|
|
50
|
+
<div role="radiogroup" aria-label={label} className={SEGMENT_TRACK}>
|
|
51
|
+
{options.map(([value, text]) => (
|
|
52
|
+
<button
|
|
53
|
+
key={value}
|
|
54
|
+
type="button"
|
|
55
|
+
role="radio"
|
|
56
|
+
aria-checked={value === current}
|
|
57
|
+
onClick={() => onSelect(value)}
|
|
58
|
+
className={segmentClass(value === current)}
|
|
59
|
+
>
|
|
60
|
+
{text}
|
|
61
|
+
</button>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
67
64
|
);
|
|
68
65
|
}
|
|
69
66
|
|
|
@@ -81,7 +78,7 @@ function Toggle({
|
|
|
81
78
|
return (
|
|
82
79
|
<button
|
|
83
80
|
type="button"
|
|
84
|
-
role="
|
|
81
|
+
role="checkbox"
|
|
85
82
|
aria-checked={checked}
|
|
86
83
|
onClick={() => onChange(!checked)}
|
|
87
84
|
className="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none hover:bg-accent focus-visible:bg-accent"
|
|
@@ -104,7 +101,7 @@ function Toggle({
|
|
|
104
101
|
}
|
|
105
102
|
|
|
106
103
|
function Heading({ children }: { children: React.ReactNode }) {
|
|
107
|
-
return <
|
|
104
|
+
return <h3 className="px-2 pb-0.5 pt-3 text-xs font-medium text-muted-foreground first:pt-0">{children}</h3>;
|
|
108
105
|
}
|
|
109
106
|
|
|
110
107
|
export function SettingsPanel({
|
|
@@ -118,176 +115,121 @@ export function SettingsPanel({
|
|
|
118
115
|
onPrefs(patch: Partial<Preferences>): void;
|
|
119
116
|
onClient(patch: Partial<ClientPreferences>): void;
|
|
120
117
|
}) {
|
|
121
|
-
const
|
|
122
|
-
const sortField = prefs.chronologicalSort === "none" ? "updated" : prefs.chronologicalSort;
|
|
123
|
-
const toggleLifecycle = (lifecycle: Lifecycle) => {
|
|
124
|
-
const has = prefs.threadLifecycles.includes(lifecycle);
|
|
125
|
-
const next = has
|
|
126
|
-
? prefs.threadLifecycles.filter((value) => value !== lifecycle)
|
|
127
|
-
: [...prefs.threadLifecycles, lifecycle];
|
|
128
|
-
if (next.length > 0) onPrefs({ threadLifecycles: next });
|
|
129
|
-
};
|
|
118
|
+
const arrow = sortArrow(prefs);
|
|
130
119
|
return (
|
|
131
|
-
<div className="flex flex-col
|
|
132
|
-
<
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
description="Also every failed or unread child"
|
|
219
|
-
value="everything"
|
|
220
|
-
current={prefs.childAttention}
|
|
221
|
-
onSelect={(value) => onPrefs({ childAttention: value })}
|
|
222
|
-
/>
|
|
223
|
-
</>
|
|
224
|
-
) : null}
|
|
225
|
-
</div>
|
|
120
|
+
<div className="flex flex-col">
|
|
121
|
+
<Heading>List</Heading>
|
|
122
|
+
<Line label="Group by">
|
|
123
|
+
<Segmented
|
|
124
|
+
label="Group by"
|
|
125
|
+
options={[
|
|
126
|
+
["project", "Project"],
|
|
127
|
+
["chronological", "Custom"],
|
|
128
|
+
["machine", "Machine"],
|
|
129
|
+
]}
|
|
130
|
+
current={prefs.organizationMode}
|
|
131
|
+
onSelect={(value) => onPrefs({ organizationMode: value })}
|
|
132
|
+
/>
|
|
133
|
+
</Line>
|
|
134
|
+
<Line label="Sort by">
|
|
135
|
+
<Segmented
|
|
136
|
+
label="Sort by"
|
|
137
|
+
options={[
|
|
138
|
+
["updated", "Updated"],
|
|
139
|
+
["created", "Created"],
|
|
140
|
+
["alpha", "A–Z"],
|
|
141
|
+
]}
|
|
142
|
+
current={effectiveSortField(prefs.chronologicalSort)}
|
|
143
|
+
onSelect={(value) => onPrefs(sortFieldPatch(value))}
|
|
144
|
+
/>
|
|
145
|
+
<button
|
|
146
|
+
type="button"
|
|
147
|
+
aria-label={`Sort order: ${arrow.label}. Reverse`}
|
|
148
|
+
title={arrow.label}
|
|
149
|
+
onClick={() => onPrefs(arrow.patch)}
|
|
150
|
+
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-surface-recessed text-xs outline-none hover:bg-state-hover focus-visible:ring-2 focus-visible:ring-ring"
|
|
151
|
+
>
|
|
152
|
+
{arrow.glyph}
|
|
153
|
+
</button>
|
|
154
|
+
</Line>
|
|
155
|
+
<Toggle label="Working threads first" checked={prefs.workingFirst} onChange={(value) => onPrefs({ workingFirst: value })} />
|
|
156
|
+
<Toggle
|
|
157
|
+
label="Worktrees as folders"
|
|
158
|
+
description="Threads sharing a worktree fold into one row"
|
|
159
|
+
checked={prefs.environmentGrouping}
|
|
160
|
+
onChange={(value) => onPrefs({ environmentGrouping: value })}
|
|
161
|
+
/>
|
|
162
|
+
<Toggle label="Collapse older threads" checked={prefs.foldOlder} onChange={(value) => onPrefs({ foldOlder: value })} />
|
|
163
|
+
<Heading>Rows</Heading>
|
|
164
|
+
<Line label="Density">
|
|
165
|
+
<Segmented
|
|
166
|
+
label="Density"
|
|
167
|
+
options={[
|
|
168
|
+
["compact", "Compact"],
|
|
169
|
+
["comfortable", "Comfortable"],
|
|
170
|
+
]}
|
|
171
|
+
current={client.density}
|
|
172
|
+
onSelect={(value) => onClient({ density: value })}
|
|
173
|
+
/>
|
|
174
|
+
</Line>
|
|
175
|
+
<Line label="Harness icon">
|
|
176
|
+
<Segmented
|
|
177
|
+
label="Harness icon"
|
|
178
|
+
options={[
|
|
179
|
+
["muted", "Muted"],
|
|
180
|
+
["colour", "Colour"],
|
|
181
|
+
["hidden", "Hidden"],
|
|
182
|
+
]}
|
|
183
|
+
current={prefs.harnessIcon}
|
|
184
|
+
onSelect={(value) => onPrefs({ harnessIcon: value })}
|
|
185
|
+
/>
|
|
186
|
+
</Line>
|
|
187
|
+
<Toggle label="Pull request badge" checked={prefs.showPullRequests} onChange={(value) => onPrefs({ showPullRequests: value })} />
|
|
188
|
+
<Heading>Show</Heading>
|
|
189
|
+
<Line label="Threads">
|
|
190
|
+
<Segmented
|
|
191
|
+
label="Threads"
|
|
192
|
+
options={[
|
|
193
|
+
["active", "Active"],
|
|
194
|
+
["archived", "Archived"],
|
|
195
|
+
["both", "Both"],
|
|
196
|
+
]}
|
|
197
|
+
current={threadsShown(prefs.threadLifecycles)}
|
|
198
|
+
onSelect={(value) => onPrefs({ threadLifecycles: lifecyclesFor(value) })}
|
|
199
|
+
/>
|
|
200
|
+
</Line>
|
|
201
|
+
<Toggle
|
|
202
|
+
label="Needs you counts every child"
|
|
203
|
+
description="Every unread or failed child thread; otherwise only those blocked on you."
|
|
204
|
+
checked={countsEveryChild(prefs.childAttention)}
|
|
205
|
+
onChange={(value) => onPrefs({ childAttention: childAttentionFor(value) })}
|
|
206
|
+
/>
|
|
226
207
|
</div>
|
|
227
208
|
);
|
|
228
209
|
}
|
|
229
210
|
|
|
211
|
+
/** The slim row above the list: ⚙ alone at its right. */
|
|
230
212
|
export function Toolbar({
|
|
231
213
|
prefs,
|
|
232
214
|
client,
|
|
233
|
-
attentionCount,
|
|
234
|
-
compact,
|
|
235
215
|
onPrefs,
|
|
236
216
|
onClient,
|
|
237
217
|
}: {
|
|
238
218
|
prefs: Preferences;
|
|
239
219
|
client: ClientPreferences;
|
|
240
|
-
/** Threads that need attention, for the filter's badge. */
|
|
241
|
-
attentionCount: number;
|
|
242
|
-
/** Phone width: the filter's long label would truncate. */
|
|
243
|
-
compact: boolean;
|
|
244
220
|
onPrefs(patch: Partial<Preferences>): void;
|
|
245
221
|
onClient(patch: Partial<ClientPreferences>): void;
|
|
246
222
|
}) {
|
|
247
223
|
return (
|
|
248
|
-
// Sticky with the group headers, so the
|
|
249
|
-
<div className="sticky top-0 z-30 flex
|
|
250
|
-
<TooltipProvider delayDuration={400}>
|
|
251
|
-
<div role="radiogroup" aria-label="Filter threads" className={cn(SEGMENT_TRACK, "min-w-0 flex-1")}>
|
|
252
|
-
{(
|
|
253
|
-
[
|
|
254
|
-
["all", "All"],
|
|
255
|
-
["attention", "Needs attention"],
|
|
256
|
-
] as const
|
|
257
|
-
).map(([value, label]) => {
|
|
258
|
-
const button = (
|
|
259
|
-
<button
|
|
260
|
-
key={value}
|
|
261
|
-
type="button"
|
|
262
|
-
role="radio"
|
|
263
|
-
aria-checked={client.filter === value}
|
|
264
|
-
aria-description={value === "attention" ? ATTENTION_EXPLANATION : undefined}
|
|
265
|
-
onClick={() => onClient({ filter: value })}
|
|
266
|
-
className={cn(segmentClass(client.filter === value), "min-w-0 flex-1 gap-1 truncate px-1.5 py-0.5 focus-visible:ring-sidebar-ring")}
|
|
267
|
-
>
|
|
268
|
-
<span className="truncate">{value === "attention" && compact ? "Attention" : label}</span>
|
|
269
|
-
{value === "attention" && attentionCount > 0 ? (
|
|
270
|
-
<span className="shrink-0 tabular-nums text-muted-foreground">{attentionCount}</span>
|
|
271
|
-
) : null}
|
|
272
|
-
</button>
|
|
273
|
-
);
|
|
274
|
-
if (value !== "attention") return button;
|
|
275
|
-
return (
|
|
276
|
-
<Tooltip key={value}>
|
|
277
|
-
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
|
278
|
-
<TooltipContent side="bottom">{ATTENTION_SUMMARY}</TooltipContent>
|
|
279
|
-
</Tooltip>
|
|
280
|
-
);
|
|
281
|
-
})}
|
|
282
|
-
</div>
|
|
283
|
-
</TooltipProvider>
|
|
224
|
+
// Sticky with the group headers, so the settings stay in reach.
|
|
225
|
+
<div style={{ height: TOOLBAR_HEIGHT }} className="sticky top-0 z-30 flex items-center justify-end bg-sidebar">
|
|
284
226
|
<Popover>
|
|
285
227
|
<PopoverTrigger asChild>
|
|
286
228
|
<button type="button" aria-label="Thread Glance settings" title="List settings" className={ROW_ICON_BUTTON}>
|
|
287
229
|
<Icon name={ICONS.settings} aria-hidden className="size-4" />
|
|
288
230
|
</button>
|
|
289
231
|
</PopoverTrigger>
|
|
290
|
-
<PopoverContent align="end" className="w-
|
|
232
|
+
<PopoverContent align="end" className="w-80 p-2">
|
|
291
233
|
<SettingsPanel prefs={prefs} client={client} onPrefs={onPrefs} onClient={onClient} />
|
|
292
234
|
</PopoverContent>
|
|
293
235
|
</Popover>
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { createContext } from "react";
|
|
5
5
|
import type { PluginSidebarSection, PluginSidebarThread } from "@get-bb/plugin-sdk/app";
|
|
6
6
|
import type { Stamps } from "@/shared/contract";
|
|
7
|
-
import type { HarnessIcon,
|
|
7
|
+
import type { HarnessIcon, OrganizationMode } from "@/shared/preferences";
|
|
8
8
|
import type { ThreadNotes } from "@/shared/contract";
|
|
9
9
|
import type { Family } from "../model/families";
|
|
10
10
|
import type { RowMenuAction } from "../model/menu";
|
|
@@ -30,7 +30,6 @@ export interface RowController {
|
|
|
30
30
|
provider(providerId: string): ProviderDisplay;
|
|
31
31
|
sections: readonly PluginSidebarSection[];
|
|
32
32
|
mode: OrganizationMode;
|
|
33
|
-
nesting: Nesting;
|
|
34
33
|
onNavigate(): void;
|
|
35
34
|
onToggleChip(row: ThreadRow): void;
|
|
36
35
|
onToggleOlder(row: OlderRow): void;
|
|
@@ -101,7 +101,7 @@ const COUNTER_ITEMS: readonly {
|
|
|
101
101
|
flag: Flag;
|
|
102
102
|
label: (count: number) => string;
|
|
103
103
|
}[] = [
|
|
104
|
-
{ key: "
|
|
104
|
+
{ key: "waitsOnYou", flag: "waits-on-you", label: (n) => `${n} need${n === 1 ? "s" : ""} you` },
|
|
105
105
|
{ key: "failed", flag: "unread-failed", label: (n) => `${n} failed` },
|
|
106
106
|
{ key: "offline", flag: "offline", label: (n) => `${n} waiting for an offline machine` },
|
|
107
107
|
{ key: "working", flag: "working", label: (n) => `${n} working` },
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Which family Needs you holds, and which family is open, while one of its threads is open. Diffed
|
|
2
|
+
// render to render, as auto-expansion is; nothing here is persisted.
|
|
3
|
+
import { useRef } from "react";
|
|
4
|
+
import type { Forest } from "../model/families";
|
|
5
|
+
import { holdNeedsYou, NO_HOLD, type NeedsYouHold } from "../model/needs-you";
|
|
6
|
+
|
|
7
|
+
export function useNeedsYouHold(forest: Forest | null, activeThreadId: string | null): string | null {
|
|
8
|
+
const held = useRef<NeedsYouHold>(NO_HOLD);
|
|
9
|
+
const lastForest = useRef<Forest | null>(null);
|
|
10
|
+
const lastActive = useRef<string | null | undefined>(undefined);
|
|
11
|
+
// Derived during render, so the family never leaves for a frame. Idempotent:
|
|
12
|
+
// a repeated render with the same inputs keeps what it stored.
|
|
13
|
+
if (forest !== null && (forest !== lastForest.current || activeThreadId !== lastActive.current)) {
|
|
14
|
+
held.current = holdNeedsYou(held.current, activeThreadId === null ? undefined : forest.familyOf.get(activeThreadId));
|
|
15
|
+
lastForest.current = forest;
|
|
16
|
+
lastActive.current = activeThreadId;
|
|
17
|
+
}
|
|
18
|
+
return held.current.heldRootId;
|
|
19
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// Group header counters
|
|
1
|
+
// Group header counters. Pure.
|
|
2
2
|
import type { Family } from "./families";
|
|
3
3
|
|
|
4
4
|
export interface Counters {
|
|
5
|
-
|
|
5
|
+
waitsOnYou: number;
|
|
6
6
|
/** Unread-failed plus queue-failed threads. */
|
|
7
7
|
failed: number;
|
|
8
8
|
offline: number;
|
|
@@ -10,21 +10,22 @@ export interface Counters {
|
|
|
10
10
|
unread: number;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export const EMPTY_COUNTERS: Counters = {
|
|
13
|
+
export const EMPTY_COUNTERS: Counters = { waitsOnYou: 0, failed: 0, offline: 0, working: 0, unread: 0 };
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* Counts the threads in the families. A thread counts what its
|
|
17
|
-
* flags say, so a child adds to
|
|
18
|
-
*
|
|
19
|
-
* only
|
|
16
|
+
* Counts the threads in the families. A thread counts what its Needs you
|
|
17
|
+
* flags say, so a child adds to waits-on-you, failed, offline and unread only
|
|
18
|
+
* as "Needs you counts every child" lets it; working counts every thread that
|
|
19
|
+
* runs. Hidden threads carry only waits-on-you and unread-failed flags
|
|
20
|
+
* already; archived threads never count.
|
|
20
21
|
*/
|
|
21
22
|
export function countFamilies(families: readonly Family[]): Counters {
|
|
22
23
|
const counters = { ...EMPTY_COUNTERS };
|
|
23
24
|
for (const family of families) {
|
|
24
25
|
for (const info of [family.root, ...family.descendants]) {
|
|
25
26
|
if (info.thread.isArchived) continue;
|
|
26
|
-
const flags = info.
|
|
27
|
-
if (flags.has("
|
|
27
|
+
const flags = info.needsYou;
|
|
28
|
+
if (flags.has("waits-on-you")) counters.waitsOnYou += 1;
|
|
28
29
|
if (flags.has("unread-failed") || flags.has("queue-failed")) counters.failed += 1;
|
|
29
30
|
if (flags.has("offline")) counters.offline += 1;
|
|
30
31
|
if (info.flags.has("working")) counters.working += 1;
|
|
@@ -36,7 +37,7 @@ export function countFamilies(families: readonly Family[]): Counters {
|
|
|
36
37
|
|
|
37
38
|
export function addCounters(a: Counters, b: Counters): Counters {
|
|
38
39
|
return {
|
|
39
|
-
|
|
40
|
+
waitsOnYou: a.waitsOnYou + b.waitsOnYou,
|
|
40
41
|
failed: a.failed + b.failed,
|
|
41
42
|
offline: a.offline + b.offline,
|
|
42
43
|
working: a.working + b.working,
|
|
@@ -44,21 +45,14 @@ export function addCounters(a: Counters, b: Counters): Counters {
|
|
|
44
45
|
};
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
/** The Needs attention tooltip: short enough to read at a glance. */
|
|
48
|
-
export const ATTENTION_SUMMARY = "Waiting on you, failed, offline, or finished and unread. Child threads count only when stuck.";
|
|
49
|
-
|
|
50
|
-
/** What the Needs attention toggle keeps, in full, for its accessible description. */
|
|
51
|
-
export const ATTENTION_EXPLANATION =
|
|
52
|
-
"Threads only you can move forward: a question, approval or plan review waiting on you; a machine that is offline; a failed thread or queued message; finished threads you haven't opened. For a child thread, only what waits on you or is offline, or a failure whose manager is idle (Filter settings can widen this).";
|
|
53
|
-
|
|
54
48
|
/**
|
|
55
|
-
* Which counters a header draws.
|
|
49
|
+
* Which counters a header draws. waits-on-you, failed and offline
|
|
56
50
|
* always; working only while collapsed, since the rows show it otherwise;
|
|
57
51
|
* unread only on the More trigger, where the rows are out of sight.
|
|
58
52
|
*/
|
|
59
53
|
export function visibleCounters(counters: Counters, where: { collapsed: boolean; more: boolean }): Counters {
|
|
60
54
|
return {
|
|
61
|
-
|
|
55
|
+
waitsOnYou: counters.waitsOnYou,
|
|
62
56
|
failed: counters.failed,
|
|
63
57
|
offline: counters.offline,
|
|
64
58
|
working: where.collapsed || where.more ? counters.working : 0,
|
|
@@ -3,7 +3,7 @@ import type { Family, ThreadInfo } from "./families";
|
|
|
3
3
|
import type { StateKind } from "./state";
|
|
4
4
|
|
|
5
5
|
const SUMMARY_ORDER: readonly [StateKind, string][] = [
|
|
6
|
-
["
|
|
6
|
+
["waits-on-you", "needing you"],
|
|
7
7
|
["failed", "failed"],
|
|
8
8
|
["queue-failed", "queue failed"],
|
|
9
9
|
["offline", "offline"],
|
|
@@ -61,7 +61,7 @@ export function sinceLabel(kind: string, since: string | null): string | null {
|
|
|
61
61
|
case "working":
|
|
62
62
|
case "background":
|
|
63
63
|
return `started ${ago}`;
|
|
64
|
-
case "
|
|
64
|
+
case "waits-on-you":
|
|
65
65
|
return `waiting since ${ago}`;
|
|
66
66
|
case "failed":
|
|
67
67
|
return `failed ${ago}`;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Auto-expansion: transitions found by diffing snapshots, and the
|
|
2
2
|
// transient targets they leave behind. Pure.
|
|
3
|
-
import { revealsOn } from "./
|
|
3
|
+
import { revealsOn } from "./needs-you";
|
|
4
4
|
import type { Forest } from "./families";
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -15,20 +15,20 @@ export type Targets = ReadonlyMap<string, TargetKind>;
|
|
|
15
15
|
export interface Snapshot {
|
|
16
16
|
activeThreadId: string | null;
|
|
17
17
|
unread: ReadonlySet<string>;
|
|
18
|
-
/** Threads that wait on you or failed, as Needs
|
|
19
|
-
|
|
18
|
+
/** Threads that wait on you or failed, as Needs you counts them. */
|
|
19
|
+
needsYou: ReadonlySet<string>;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export function snapshotOf(forest: Forest, activeThreadId: string | null): Snapshot {
|
|
23
23
|
const unread = new Set<string>();
|
|
24
|
-
const
|
|
24
|
+
const needsYou = new Set<string>();
|
|
25
25
|
for (const info of forest.infos.values()) {
|
|
26
26
|
if (info.thread.isArchived) continue;
|
|
27
|
-
// A finished child that
|
|
28
|
-
if (info.
|
|
29
|
-
if (revealsOn(info.
|
|
27
|
+
// A finished child that does not need you never opens anything.
|
|
28
|
+
if (info.needsYou.has("unread") && !info.thread.isHidden) unread.add(info.thread.id);
|
|
29
|
+
if (revealsOn(info.needsYou, info.parentId === null)) needsYou.add(info.thread.id);
|
|
30
30
|
}
|
|
31
|
-
return { activeThreadId, unread,
|
|
31
|
+
return { activeThreadId, unread, needsYou };
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
|
@@ -40,8 +40,8 @@ export function detectTransitions(previous: Snapshot | null, next: Snapshot): Ma
|
|
|
40
40
|
if (next.activeThreadId !== null && next.activeThreadId !== previous?.activeThreadId) {
|
|
41
41
|
found.set(next.activeThreadId, "reveal");
|
|
42
42
|
}
|
|
43
|
-
for (const id of next.
|
|
44
|
-
if (!previous?.
|
|
43
|
+
for (const id of next.needsYou) {
|
|
44
|
+
if (!previous?.needsYou.has(id)) found.set(id, "reveal");
|
|
45
45
|
}
|
|
46
46
|
for (const id of next.unread) {
|
|
47
47
|
if (id === next.activeThreadId || found.has(id)) continue;
|