@gperezmz/bb-plugin-thread-glance 0.2.4 → 0.4.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/README.md +1 -1
- package/app.tsx +3 -11
- package/dist/app.css +1 -1
- package/dist/app.js +31 -31
- 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 +21 -30
- package/features/thread-list/components/GroupSection.tsx +7 -45
- package/features/thread-list/components/ListHeader.tsx +79 -0
- package/features/thread-list/components/ProviderBadge.tsx +0 -1
- package/features/thread-list/components/PullRequestProbes.tsx +27 -0
- package/features/thread-list/components/SettingsPanel.tsx +20 -34
- package/features/thread-list/components/ThreadDetails.tsx +3 -3
- package/features/thread-list/components/ThreadList.tsx +117 -83
- package/features/thread-list/components/ThreadRowView.tsx +203 -180
- package/features/thread-list/components/controller.ts +5 -10
- package/features/thread-list/components/glyphs.tsx +10 -16
- package/features/thread-list/data/useAutoExpand.ts +1 -1
- package/features/thread-list/data/useDefaultBranches.ts +35 -0
- package/features/thread-list/data/usePullRequestAnswers.ts +36 -0
- package/features/thread-list/data/useSystemFacts.ts +52 -0
- package/features/thread-list/icons.ts +1 -0
- package/features/thread-list/index.ts +0 -1
- package/features/thread-list/model/attention.ts +2 -66
- package/features/thread-list/model/branches.ts +27 -0
- package/features/thread-list/model/counters.ts +5 -5
- package/features/thread-list/model/details.ts +6 -6
- package/features/thread-list/model/expansion.ts +5 -5
- package/features/thread-list/model/groups.ts +2 -2
- package/features/thread-list/model/labels.ts +43 -18
- package/features/thread-list/model/menu.ts +1 -0
- package/features/thread-list/model/move.ts +1 -1
- package/features/thread-list/model/notes.ts +2 -28
- package/features/thread-list/model/settings.ts +1 -17
- package/features/thread-list/model/settled.ts +122 -0
- package/features/thread-list/model/share.ts +1 -7
- package/features/thread-list/model/sort.ts +5 -16
- package/features/thread-list/model/state.ts +17 -16
- package/features/thread-list/model/time.ts +7 -0
- package/features/thread-list/model/toggles.ts +35 -24
- package/features/thread-list/model/{families.ts → trees.ts} +40 -28
- package/features/thread-list/model/view.ts +202 -274
- package/features/thread-list/testing/browser.css +13 -0
- package/features/thread-list/testing/fixtures.ts +41 -12
- package/package.json +5 -1
- package/server/cli.ts +1 -1
- package/server/import.ts +36 -6
- package/server/preference-store.ts +2 -1
- package/server.ts +1 -1
- package/shared/preferences.ts +38 -27
- package/skills/thread-glance/SKILL.md +11 -12
- package/features/thread-list/data/useAttentionHold.ts +0 -19
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// What Thread Glance reads of bb's own configuration: its default harness
|
|
2
|
+
// and its primary machine. Asked once per list; null until answered.
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { useSdk } from "@get-bb/plugin-sdk/app";
|
|
5
|
+
|
|
6
|
+
export interface SystemFacts {
|
|
7
|
+
/** The harness a new thread starts with, when bb says. */
|
|
8
|
+
defaultProviderId: string | null;
|
|
9
|
+
/** The machine bb itself runs on, when one is connected. */
|
|
10
|
+
primaryHostId: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const UNKNOWN_SYSTEM: SystemFacts = { defaultProviderId: null, primaryHostId: null };
|
|
14
|
+
|
|
15
|
+
interface SystemConfig {
|
|
16
|
+
primaryHostId: string | null;
|
|
17
|
+
generalSettings: { defaultProviderId: string | null };
|
|
18
|
+
serverAccess: { defaultProviderId: string };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The facts in `system.config()`'s answer. The default harness is the one
|
|
23
|
+
* the user chose in bb's settings, else the one bb falls back to.
|
|
24
|
+
*/
|
|
25
|
+
export function readSystemFacts(config: SystemConfig): SystemFacts {
|
|
26
|
+
return {
|
|
27
|
+
defaultProviderId: config.generalSettings.defaultProviderId ?? config.serverAccess.defaultProviderId ?? null,
|
|
28
|
+
primaryHostId: config.primaryHostId,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function useSystemFacts(): SystemFacts {
|
|
33
|
+
const sdk = useSdk();
|
|
34
|
+
const [facts, setFacts] = useState<SystemFacts>(UNKNOWN_SYSTEM);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
let cancelled = false;
|
|
37
|
+
// Called inside the chain, so a host that throws for an area it lacks
|
|
38
|
+
// lands in the rejection handler and leaves the facts unknown.
|
|
39
|
+
Promise.resolve()
|
|
40
|
+
.then(() => sdk.system.config())
|
|
41
|
+
.then(
|
|
42
|
+
(config) => {
|
|
43
|
+
if (!cancelled) setFacts(readSystemFacts(config));
|
|
44
|
+
},
|
|
45
|
+
() => undefined,
|
|
46
|
+
);
|
|
47
|
+
return () => {
|
|
48
|
+
cancelled = true;
|
|
49
|
+
};
|
|
50
|
+
}, [sdk]);
|
|
51
|
+
return facts;
|
|
52
|
+
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
// What needs attention, thread by thread.
|
|
2
|
-
// counters, the
|
|
1
|
+
// What needs attention, thread by thread. Collapsed groups, the header
|
|
2
|
+
// counters, the need-you filter and auto-reveal all read the set this
|
|
3
3
|
// builds. Pure.
|
|
4
4
|
import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
|
|
5
5
|
import type { ChildAttention } from "@/shared/preferences";
|
|
6
|
-
import type { Family, Forest } from "./families";
|
|
7
6
|
import { type Flag, type StateKind, type ThreadState } from "./state";
|
|
8
7
|
|
|
9
8
|
/** The flags that count for a root thread. Working is not one. */
|
|
@@ -88,66 +87,3 @@ export function revealsOn(attention: ReadonlySet<Flag>, isRoot: boolean): boolea
|
|
|
88
87
|
if (attention.has("waits-on-you") || attention.has("unread-failed")) return true;
|
|
89
88
|
return !isRoot && attention.has("queue-failed");
|
|
90
89
|
}
|
|
91
|
-
|
|
92
|
-
/** What the section reads of a family. */
|
|
93
|
-
export type SectionFamily = Pick<Family, "root" | "attentionFlags" | "latestAttentionAt">;
|
|
94
|
-
|
|
95
|
-
/** What Needs attention remembers between renders: the family it holds, and the family that was open. */
|
|
96
|
-
export interface AttentionHold {
|
|
97
|
-
/**
|
|
98
|
-
* The family held in the section, if any, as it was when opened. The section
|
|
99
|
-
* orders it by this, so nothing that happens inside it moves it.
|
|
100
|
-
*/
|
|
101
|
-
held: SectionFamily | null;
|
|
102
|
-
/** The root of the family the open thread belonged to at the last render, if any. */
|
|
103
|
-
openRootId: string | null;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export const NO_HOLD: AttentionHold = { held: null, openRootId: null };
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Whether a family is in the Needs attention section: it is the held family, or one
|
|
110
|
-
* of its threads needs attention and none of them is open. Nothing moves while you
|
|
111
|
-
* are inside a family, so the open family only enters by being judged when
|
|
112
|
-
* opened (see `holdAttention`).
|
|
113
|
-
*/
|
|
114
|
-
export function inAttention(family: SectionFamily, heldRootId: string | null, openRootId: string | null): boolean {
|
|
115
|
-
const rootId = family.root.thread.id;
|
|
116
|
-
if (rootId === heldRootId) return true;
|
|
117
|
-
return family.attentionFlags.size > 0 && rootId !== openRootId;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Whether a family is attended: held in the section with nothing in it needing
|
|
122
|
-
* attention any more. It keeps its place and draws and counts as in its home group.
|
|
123
|
-
*/
|
|
124
|
-
export function isAttended(family: SectionFamily, heldRootId: string | null): boolean {
|
|
125
|
-
return family.root.thread.id === heldRootId && family.attentionFlags.size === 0;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* The hold after a render. A family is judged when a thread in it is opened
|
|
130
|
-
* from outside it: it is held if something in it needs attention then, and it stays
|
|
131
|
-
* held while one of its threads is open, after nothing in it needs attention any
|
|
132
|
-
* more, until none of its threads is open. An attended family whose open thread
|
|
133
|
-
* is archived counts as closed. A family that does not need attention
|
|
134
|
-
* when opened is not pulled in later, whatever happens in it; nor is one
|
|
135
|
-
* that never needed attention. Opening another thread of the open family judges
|
|
136
|
-
* nothing again.
|
|
137
|
-
*/
|
|
138
|
-
export function holdAttention(
|
|
139
|
-
previous: AttentionHold,
|
|
140
|
-
forest: Pick<Forest, "infos" | "familyOf">,
|
|
141
|
-
activeThreadId: string | null,
|
|
142
|
-
): AttentionHold {
|
|
143
|
-
if (activeThreadId === null) return NO_HOLD;
|
|
144
|
-
const openFamily = forest.familyOf.get(activeThreadId);
|
|
145
|
-
if (openFamily === undefined) return NO_HOLD;
|
|
146
|
-
const openRootId = openFamily.root.thread.id;
|
|
147
|
-
if (openRootId === previous.openRootId) {
|
|
148
|
-
const archived = forest.infos.get(activeThreadId)?.thread.isArchived ?? false;
|
|
149
|
-
const held = previous.held?.root.thread.id === openRootId && !(archived && openFamily.attentionFlags.size === 0);
|
|
150
|
-
return held ? previous : { held: null, openRootId };
|
|
151
|
-
}
|
|
152
|
-
return { held: openFamily.attentionFlags.size > 0 ? openFamily : null, openRootId };
|
|
153
|
-
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The project's default branch, and whether a thread's branch is another
|
|
2
|
+
// one. Pure.
|
|
3
|
+
|
|
4
|
+
/** A project source as `projects.get` returns it, narrowed to what the lookup reads. */
|
|
5
|
+
export interface ProjectSourceRef {
|
|
6
|
+
hostId: string;
|
|
7
|
+
isDefault: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The machine to ask for a project's default branch: its default source's,
|
|
12
|
+
* else its first source's. Null when the project has no source, as a
|
|
13
|
+
* personal project may not. A thread's own machine may have no source for
|
|
14
|
+
* the project, and bb answers 404 there.
|
|
15
|
+
*/
|
|
16
|
+
export function defaultSourceHostId(sources: readonly ProjectSourceRef[]): string | null {
|
|
17
|
+
return (sources.find((source) => source.isDefault) ?? sources[0])?.hostId ?? null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether a thread's branch is known to differ from its project's default:
|
|
22
|
+
* `defaultBranch` is undefined while the lookup runs and null when it found
|
|
23
|
+
* none, and in both cases the branch is not known to differ.
|
|
24
|
+
*/
|
|
25
|
+
export function isOffDefaultBranch(branch: string | null, defaultBranch: string | null | undefined): branch is string {
|
|
26
|
+
return branch !== null && typeof defaultBranch === "string" && branch !== defaultBranch;
|
|
27
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Group header counters. Pure.
|
|
2
|
-
import type {
|
|
2
|
+
import type { ThreadTree } from "./trees";
|
|
3
3
|
|
|
4
4
|
export interface Counters {
|
|
5
5
|
waitsOnYou: number;
|
|
@@ -13,16 +13,16 @@ export interface Counters {
|
|
|
13
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
|
|
16
|
+
* Counts the threads in the trees. A thread counts what its Needs attention
|
|
17
17
|
* flags say, so a child adds to waits-on-you, failed, offline and unread only
|
|
18
18
|
* as "Needs attention counts every child" lets it; working counts every thread that
|
|
19
19
|
* runs. Hidden threads carry only waits-on-you and unread-failed flags
|
|
20
20
|
* already; archived threads never count.
|
|
21
21
|
*/
|
|
22
|
-
export function
|
|
22
|
+
export function countTrees(trees: readonly ThreadTree[]): Counters {
|
|
23
23
|
const counters = { ...EMPTY_COUNTERS };
|
|
24
|
-
for (const
|
|
25
|
-
for (const info of [
|
|
24
|
+
for (const tree of trees) {
|
|
25
|
+
for (const info of [tree.root, ...tree.descendants]) {
|
|
26
26
|
if (info.thread.isArchived) continue;
|
|
27
27
|
const flags = info.attentionFlags;
|
|
28
28
|
if (flags.has("waits-on-you")) counters.waitsOnYou += 1;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Hover card and details drawer content. Pure.
|
|
2
|
-
import type {
|
|
2
|
+
import type { ThreadTree, ThreadInfo } from "./trees";
|
|
3
3
|
import type { StateKind } from "./state";
|
|
4
4
|
|
|
5
5
|
const SUMMARY_ORDER: readonly [StateKind, string][] = [
|
|
@@ -29,13 +29,13 @@ export function childSummary(descendants: readonly ThreadInfo[]): string | null
|
|
|
29
29
|
return parts.length > 0 ? parts.join(", ") : null;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/** Descendants of `id` inside its
|
|
33
|
-
export function descendantsOf(
|
|
34
|
-
if (
|
|
35
|
-
if (
|
|
32
|
+
/** Descendants of `id` inside its tree, for the card of any row. */
|
|
33
|
+
export function descendantsOf(tree: ThreadTree | undefined, id: string): ThreadInfo[] {
|
|
34
|
+
if (tree === undefined) return [];
|
|
35
|
+
if (tree.root.thread.id === id) return tree.descendants;
|
|
36
36
|
const result: ThreadInfo[] = [];
|
|
37
37
|
const within = new Set([id]);
|
|
38
|
-
for (const info of
|
|
38
|
+
for (const info of tree.descendants) {
|
|
39
39
|
if (info.parentId !== null && within.has(info.parentId)) {
|
|
40
40
|
within.add(info.thread.id);
|
|
41
41
|
result.push(info);
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// Auto-expansion: transitions found by diffing snapshots, and the
|
|
2
2
|
// transient targets they leave behind. Pure.
|
|
3
3
|
import { revealsOn } from "./attention";
|
|
4
|
-
import type { Forest } from "./
|
|
4
|
+
import type { Forest } from "./trees";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* `reveal` targets open
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* `reveal` targets open the parent chips on their path. `open` targets (a
|
|
8
|
+
* thread that newly became unread) open no chip, so finished children don't
|
|
9
|
+
* expand their parent's. The open thread's target, of either kind, also
|
|
10
|
+
* opens its collapsed group. No target opens a settled fold.
|
|
11
11
|
*/
|
|
12
12
|
export type TargetKind = "reveal" | "open";
|
|
13
13
|
export type Targets = ReadonlyMap<string, TargetKind>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Top-level groups: which group a
|
|
1
|
+
// Top-level groups: which group a tree lands in, and the order
|
|
2
2
|
// groups appear in. Pure.
|
|
3
3
|
import type {
|
|
4
4
|
PluginSidebarProject,
|
|
@@ -43,7 +43,7 @@ export function personalProject(
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* The group a
|
|
46
|
+
* The group a tree root belongs to. Pinned roots go to Pinned. A root
|
|
47
47
|
* whose project, section or machine isn't known goes to the loose Threads
|
|
48
48
|
* bucket, except an unknown section, which gets a placeholder group as in
|
|
49
49
|
* bb.
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Accessible names: everything a tooltip says is in the aria-label.
|
|
2
|
-
import type { OlderRow, ThreadRow } from "./view";
|
|
3
|
-
import {
|
|
2
|
+
import type { Chip, OlderRow, SettledRow, ThreadRow } from "./view";
|
|
3
|
+
import type { Flag } from "./state";
|
|
4
4
|
import { formatDateTime } from "./details";
|
|
5
|
+
import type { OrganizationMode } from "@/shared/preferences";
|
|
5
6
|
|
|
6
7
|
export function stateText(row: ThreadRow, pluginLabel: string | null): string {
|
|
7
8
|
const state = row.info.state;
|
|
@@ -20,32 +21,56 @@ export function rowAriaLabel(
|
|
|
20
21
|
const parts = [stateText(row, options.pluginLabel), options.providerName];
|
|
21
22
|
if (row.parentTitle !== null && row.depth > 0) parts.push(`child of ${row.parentTitle}`);
|
|
22
23
|
if (row.crossGroupLabel !== null) parts.push(row.crossGroupLabel.toLowerCase());
|
|
23
|
-
if (row.homeGroupLabel !== null) parts.push(`in ${row.homeGroupLabel}`);
|
|
24
24
|
if (row.hiddenBadge) parts.push("hidden thread");
|
|
25
25
|
if (row.info.unread && row.info.state.kind !== "unread") parts.push("unread");
|
|
26
26
|
if (options.hasDraft && row.info.state.kind !== "draft") parts.push("unsubmitted draft");
|
|
27
27
|
return `Open ${row.info.thread.displayTitle} — ${parts.join("; ")}`;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
/** What a children chip's state says, uncounted: the state can come from any depth, the count only from direct children. */
|
|
31
|
+
const CHIP_STATE_TEXT: Readonly<Record<Flag, string>> = {
|
|
32
|
+
"waits-on-you": "waiting on you below",
|
|
33
|
+
"unread-failed": "failed below",
|
|
34
|
+
"queue-failed": "queued message failed below",
|
|
35
|
+
offline: "machine offline below",
|
|
36
|
+
working: "working below",
|
|
37
|
+
unread: "unread below",
|
|
38
|
+
};
|
|
35
39
|
|
|
36
40
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
41
|
+
* "Show 2 child threads of Release, working below"; with no visible child,
|
|
42
|
+
* "Show hidden child threads of Release, waiting on you below".
|
|
39
43
|
*/
|
|
44
|
+
export function chipLabel(title: string, chip: Chip): string {
|
|
45
|
+
const children = chip.count === 0 ? "hidden child threads" : `${chip.count} ${chip.count === 1 ? "child thread" : "child threads"}`;
|
|
46
|
+
const state = chip.flag === null ? "" : `, ${CHIP_STATE_TEXT[chip.flag]}`;
|
|
47
|
+
return `${chip.expanded ? "Collapse" : "Show"} ${children} of ${title}${state}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The text and accessible name of an open tree's fold row. */
|
|
40
51
|
export function olderRowText(row: OlderRow): { label: string; ariaLabel: string } {
|
|
41
|
-
const noun = row.count === 1 ? "thread" : "threads";
|
|
42
|
-
if (row.scope === "group") {
|
|
43
|
-
return {
|
|
44
|
-
label: row.expanded ? "Show fewer" : `${row.count} older`,
|
|
45
|
-
ariaLabel: `${row.expanded ? "Hide" : "Show"} ${row.count} older ${noun}`,
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
52
|
const children = row.count === 1 ? "child thread" : "child threads";
|
|
49
|
-
if (row.expanded) return { label: "Show fewer", ariaLabel: `Hide ${row.count}
|
|
53
|
+
if (row.expanded) return { label: "Show fewer", ariaLabel: `Hide ${row.count} more ${children}` };
|
|
50
54
|
return { label: `${row.count} more ${children}`, ariaLabel: `Show ${row.count} more ${children}` };
|
|
51
55
|
}
|
|
56
|
+
|
|
57
|
+
/** The settled fold's text: "Settled (N)" while closed, "Settled" while open. N counts trees. */
|
|
58
|
+
export function settledRowText(row: SettledRow): { label: string; ariaLabel: string } {
|
|
59
|
+
const noun = row.count === 1 ? "settled thread tree" : "settled thread trees";
|
|
60
|
+
return {
|
|
61
|
+
label: row.expanded ? "Settled" : `Settled (${row.count})`,
|
|
62
|
+
ariaLabel: `${row.expanded ? "Hide" : "Show"} ${row.count} ${noun}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The list header's name for the current grouping. */
|
|
67
|
+
export function groupingName(mode: OrganizationMode): string {
|
|
68
|
+
switch (mode) {
|
|
69
|
+
case "project":
|
|
70
|
+
return "Projects";
|
|
71
|
+
case "chronological":
|
|
72
|
+
return "Sections";
|
|
73
|
+
case "machine":
|
|
74
|
+
return "Machines";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -30,6 +30,7 @@ export interface RowMenuItem {
|
|
|
30
30
|
|
|
31
31
|
export interface RowMenuInputs {
|
|
32
32
|
thread: PluginSidebarThread;
|
|
33
|
+
/** The thread is unread; for a root, anything in its tree is, and Mark read marks the tree. */
|
|
33
34
|
unread: boolean;
|
|
34
35
|
splitAvailable: boolean;
|
|
35
36
|
/** True for a thread with no parent (bb: Move to section on roots only). */
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// the server stores what it saw; this decides what a row says.
|
|
3
3
|
import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
|
|
4
4
|
import type { Note, ThreadNotes } from "@/shared/contract";
|
|
5
|
-
import { normalizeQueued, normalizeStatus, type
|
|
5
|
+
import { normalizeQueued, normalizeStatus, type NeedsKind } from "./state";
|
|
6
6
|
|
|
7
7
|
export interface RowNote {
|
|
8
|
-
/** "Asks", "Approve", "Plan", "
|
|
8
|
+
/** "Asks", "Approve", "Plan", "Needs" or "Failed". */
|
|
9
9
|
prefix: string;
|
|
10
10
|
/** Empty when there is nothing to add: the prefix stands alone. */
|
|
11
11
|
text: string;
|
|
@@ -48,32 +48,6 @@ export function rowNote(thread: PluginSidebarThread, notes: ThreadNotes | undefi
|
|
|
48
48
|
return null;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/**
|
|
52
|
-
* The line under a row in Needs attention that itself needs attention: its
|
|
53
|
-
* most urgent reason among `attention`, its Needs attention flags. Waits on
|
|
54
|
-
* you, then failed, then offline, then finished; each keeps its prefix when
|
|
55
|
-
* there is no text to follow it.
|
|
56
|
-
*/
|
|
57
|
-
export function attentionNote(
|
|
58
|
-
thread: PluginSidebarThread,
|
|
59
|
-
notes: ThreadNotes | undefined,
|
|
60
|
-
attention: ReadonlySet<Flag>,
|
|
61
|
-
): RowNote | null {
|
|
62
|
-
if (attention.has("waits-on-you")) {
|
|
63
|
-
return { prefix: PENDING_PREFIX[needsKindOf(notes) ?? "input"], text: notes?.pending?.text.trim() ?? "", tone: "attention" };
|
|
64
|
-
}
|
|
65
|
-
if (attention.has("unread-failed")) {
|
|
66
|
-
const text = notes?.failed?.text.trim() ?? "";
|
|
67
|
-
return { prefix: "Failed", text: text.toLowerCase() === "failed" ? "" : text, tone: "destructive" };
|
|
68
|
-
}
|
|
69
|
-
if (attention.has("queue-failed")) {
|
|
70
|
-
return { prefix: "Failed", text: "queued message wasn't sent", tone: "destructive" };
|
|
71
|
-
}
|
|
72
|
-
if (attention.has("offline")) return { prefix: "Offline", text: thread.host?.name.trim() ?? "", tone: "attention" };
|
|
73
|
-
if (attention.has("unread")) return { prefix: "Finished", text: lastReply(notes)?.text.trim() ?? "", tone: "muted" };
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
51
|
/** A note as one line of text: "Failed: timeout", or "Failed" alone. */
|
|
78
52
|
export function noteText(note: RowNote): string {
|
|
79
53
|
return note.text === "" ? note.prefix : `${note.prefix}: ${note.text}`;
|
|
@@ -1,23 +1,7 @@
|
|
|
1
1
|
// What the settings panel's controls show and write. Pure.
|
|
2
|
-
import type { ChildAttention,
|
|
2
|
+
import type { ChildAttention, Preferences, SortField } from "@/shared/preferences";
|
|
3
3
|
import { effectiveDirection, effectiveSortField, naturalDirection } from "./sort";
|
|
4
4
|
|
|
5
|
-
export type ThreadsShown = "active" | "archived" | "both";
|
|
6
|
-
|
|
7
|
-
/** The Threads choice a saved lifecycle list stands for. */
|
|
8
|
-
export function threadsShown(lifecycles: readonly Lifecycle[]): ThreadsShown {
|
|
9
|
-
const active = lifecycles.includes("active");
|
|
10
|
-
const archived = lifecycles.includes("archived");
|
|
11
|
-
if (active && archived) return "both";
|
|
12
|
-
return archived ? "archived" : "active";
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** The lifecycles a Threads choice saves; never empty. */
|
|
16
|
-
export function lifecyclesFor(shown: ThreadsShown): Lifecycle[] {
|
|
17
|
-
if (shown === "both") return ["active", "archived"];
|
|
18
|
-
return [shown];
|
|
19
|
-
}
|
|
20
|
-
|
|
21
5
|
/** "Needs attention counts every child" is on when children count as `everything`. */
|
|
22
6
|
export function countsEveryChild(childAttention: ChildAttention): boolean {
|
|
23
7
|
return childAttention === "everything";
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Settled threads: which trees go behind their group's settled fold.
|
|
2
|
+
// Worked out afresh on every build, so any activity unsettles a thread. Pure.
|
|
3
|
+
import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
|
|
4
|
+
import type { SettleAfter } from "@/shared/preferences";
|
|
5
|
+
import { isQuietThread } from "./state";
|
|
6
|
+
import type { ThreadInfo, ThreadTree } from "./trees";
|
|
7
|
+
|
|
8
|
+
const HOUR = 60 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
/** How long a thread goes without activity before it settles, per Settle after choice. */
|
|
11
|
+
export const SETTLE_AFTER_MS: Readonly<Record<Exclude<SettleAfter, "never">, number>> = {
|
|
12
|
+
"12h": 12 * HOUR,
|
|
13
|
+
"1d": 24 * HOUR,
|
|
14
|
+
"3d": 3 * 24 * HOUR,
|
|
15
|
+
"1w": 7 * 24 * HOUR,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** A pull request's state, as bb's lookup reports it. */
|
|
19
|
+
export type PullRequestState = "open" | "draft" | "merged" | "closed";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What settling knows of a thread's pull request: its state, null when the
|
|
23
|
+
* thread has none, or `unknown` while a lookup it needs has not answered.
|
|
24
|
+
*/
|
|
25
|
+
export type PullRequestFact = PullRequestState | null | "unknown";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Whether a thread's branch can carry a pull request worth looking up: it is
|
|
29
|
+
* on a branch other than its project's default. A default branch the lookup
|
|
30
|
+
* could not find (null) is no reason to skip one; one still being looked up
|
|
31
|
+
* (undefined) is, until it answers.
|
|
32
|
+
*/
|
|
33
|
+
export function needsPullRequestLookup(branch: string | null, defaultBranch: string | null | undefined): boolean {
|
|
34
|
+
return branch !== null && defaultBranch !== undefined && branch !== defaultBranch;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A thread's pull request fact, from its project's default branch and the lookups that answered. */
|
|
38
|
+
export function pullRequestFact(
|
|
39
|
+
thread: Pick<PluginSidebarThread, "id" | "environment">,
|
|
40
|
+
defaultBranch: string | null | undefined,
|
|
41
|
+
answers: ReadonlyMap<string, PullRequestState | null>,
|
|
42
|
+
): PullRequestFact {
|
|
43
|
+
const branch = thread.environment?.branchName ?? null;
|
|
44
|
+
if (branch === null) return null;
|
|
45
|
+
if (defaultBranch === undefined) return "unknown";
|
|
46
|
+
if (!needsPullRequestLookup(branch, defaultBranch)) return null;
|
|
47
|
+
const answer = answers.get(thread.id);
|
|
48
|
+
return answer === undefined ? "unknown" : answer;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SettleInputs {
|
|
52
|
+
now: number;
|
|
53
|
+
settleAfter: SettleAfter;
|
|
54
|
+
/** Server stamps: when each thread last started and finished a turn. */
|
|
55
|
+
startedAt: Readonly<Record<string, number>>;
|
|
56
|
+
finishedAt: Readonly<Record<string, number>>;
|
|
57
|
+
pullRequestOf(thread: PluginSidebarThread): PullRequestFact;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A thread's own last activity: its creation, its last finish or failure
|
|
62
|
+
* (`latestAttentionAt`) and the server's stamps of its last start and
|
|
63
|
+
* finish. `updatedAt` is left out because opening or renaming a thread
|
|
64
|
+
* moves it too.
|
|
65
|
+
*/
|
|
66
|
+
export function lastActivityAt(
|
|
67
|
+
thread: Pick<PluginSidebarThread, "id" | "createdAt" | "latestAttentionAt">,
|
|
68
|
+
stamps: Pick<SettleInputs, "startedAt" | "finishedAt">,
|
|
69
|
+
): number {
|
|
70
|
+
return Math.max(
|
|
71
|
+
thread.createdAt,
|
|
72
|
+
thread.latestAttentionAt,
|
|
73
|
+
stamps.startedAt[thread.id] ?? 0,
|
|
74
|
+
stamps.finishedAt[thread.id] ?? 0,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A settled thread: quiet as if no thread were open, not needing attention,
|
|
80
|
+
* not pinned, with no open pull request, and either its pull request merged
|
|
81
|
+
* or closed or its own last activity is older than the Settle after period.
|
|
82
|
+
* Hidden and archived threads take the same test.
|
|
83
|
+
*/
|
|
84
|
+
export function isSettledThread(info: ThreadInfo, inputs: SettleInputs): boolean {
|
|
85
|
+
const thread = info.thread;
|
|
86
|
+
if (!mightSettle(info)) return false;
|
|
87
|
+
const pullRequest = inputs.pullRequestOf(thread);
|
|
88
|
+
if (pullRequest === "unknown" || pullRequest === "open" || pullRequest === "draft") return false;
|
|
89
|
+
if (pullRequest === "merged" || pullRequest === "closed") return true;
|
|
90
|
+
if (inputs.settleAfter === "never") return false;
|
|
91
|
+
return inputs.now - lastActivityAt(thread, inputs) >= SETTLE_AFTER_MS[inputs.settleAfter];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A row's thread that settles once its pull request and activity allow: quiet as if none were open, needing no attention, not pinned. */
|
|
95
|
+
function mightSettle(info: ThreadInfo): boolean {
|
|
96
|
+
const thread = info.thread;
|
|
97
|
+
if (thread.pinnedAt !== null || thread.isPinned) return false;
|
|
98
|
+
return isQuietThread(info.state, info.unread, false) && info.attentionFlags.size === 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A tree settles as one unit: when every thread in it is settled. */
|
|
102
|
+
export function isSettledTree(tree: Pick<ThreadTree, "root" | "descendants">, inputs: SettleInputs): boolean {
|
|
103
|
+
return isSettledThread(tree.root, inputs) && tree.descendants.every((info) => isSettledThread(info, inputs));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The threads whose pull request settling has to look up: every thread that
|
|
108
|
+
* would settle but for its pull request, on a branch other than its
|
|
109
|
+
* project's default.
|
|
110
|
+
*/
|
|
111
|
+
export function pullRequestLookupIds(
|
|
112
|
+
infos: Iterable<ThreadInfo>,
|
|
113
|
+
defaultBranchOf: (thread: PluginSidebarThread) => string | null | undefined,
|
|
114
|
+
): string[] {
|
|
115
|
+
const ids: string[] = [];
|
|
116
|
+
for (const info of infos) {
|
|
117
|
+
const thread = info.thread;
|
|
118
|
+
if (!mightSettle(info)) continue;
|
|
119
|
+
if (needsPullRequestLookup(thread.environment?.branchName ?? null, defaultBranchOf(thread))) ids.push(thread.id);
|
|
120
|
+
}
|
|
121
|
+
return ids;
|
|
122
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Structural sharing between two list views: whatever did not change keeps
|
|
2
2
|
// the previous object, so memoized rows and groups skip their render. Pure.
|
|
3
|
-
import type { GroupView, ListView,
|
|
3
|
+
import type { GroupView, ListView, Row } from "./view";
|
|
4
4
|
|
|
5
5
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
6
6
|
if (value === null || typeof value !== "object") return false;
|
|
@@ -86,11 +86,6 @@ function shareGroups(previousGroups: GroupView[], all: ReadonlyMap<string, Group
|
|
|
86
86
|
return sameItems(previousGroups, result) ? previousGroups : result;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
function shareAttention(previous: AttentionView | null, next: AttentionView | null): AttentionView | null {
|
|
90
|
-
if (previous === null || next === null) return next;
|
|
91
|
-
return shareFields(previous, next, { rows: shareRows(previous.rows, next.rows) });
|
|
92
|
-
}
|
|
93
|
-
|
|
94
89
|
/**
|
|
95
90
|
* `next`, sharing every group and row that did not change since `previous`.
|
|
96
91
|
* Groups match by id and rows by key, never by position. A group keeps its
|
|
@@ -100,7 +95,6 @@ export function shareView(previous: ListView | null, next: ListView): ListView {
|
|
|
100
95
|
if (previous === null) return next;
|
|
101
96
|
const all = new Map([...previous.groups, ...previous.more].map((group) => [group.descriptor.id, group]));
|
|
102
97
|
return shareFields(previous, next, {
|
|
103
|
-
attention: shareAttention(previous.attention, next.attention),
|
|
104
98
|
groups: shareGroups(previous.groups, all, next.groups),
|
|
105
99
|
more: shareGroups(previous.more, all, next.more),
|
|
106
100
|
});
|
|
@@ -5,14 +5,13 @@ import type { SortDirection, SortField } from "@/shared/preferences";
|
|
|
5
5
|
export interface SortOptions {
|
|
6
6
|
field: SortField;
|
|
7
7
|
direction: SortDirection;
|
|
8
|
-
workingFirst: boolean;
|
|
9
8
|
}
|
|
10
9
|
|
|
11
10
|
/** What a comparator needs to know about a root beyond the thread itself. */
|
|
12
11
|
export interface SortKey {
|
|
13
12
|
thread: PluginSidebarThread;
|
|
14
|
-
/** Largest `latestAttentionAt` over the
|
|
15
|
-
|
|
13
|
+
/** Largest `latestAttentionAt` over the tree. */
|
|
14
|
+
treeAttention: number;
|
|
16
15
|
}
|
|
17
16
|
|
|
18
17
|
function byCreated(a: SortKey, b: SortKey): number {
|
|
@@ -28,7 +27,7 @@ function byAlpha(a: SortKey, b: SortKey): number {
|
|
|
28
27
|
}
|
|
29
28
|
|
|
30
29
|
function byUpdated(a: SortKey, b: SortKey): number {
|
|
31
|
-
const diff = b.
|
|
30
|
+
const diff = b.treeAttention - a.treeAttention;
|
|
32
31
|
if (diff !== 0) return diff;
|
|
33
32
|
return byCreated(a, b);
|
|
34
33
|
}
|
|
@@ -48,22 +47,12 @@ export function effectiveDirection(field: SortField, direction: SortDirection):
|
|
|
48
47
|
return direction === "default" ? naturalDirection(field) : direction;
|
|
49
48
|
}
|
|
50
49
|
|
|
51
|
-
/**
|
|
52
|
-
* The comparator for roots in a group. Working first keeps active threads on
|
|
53
|
-
* top whatever the direction.
|
|
54
|
-
*/
|
|
50
|
+
/** The comparator for roots in a group. */
|
|
55
51
|
export function makeComparator(options: SortOptions): (a: SortKey, b: SortKey) => number {
|
|
56
52
|
const field = effectiveSortField(options.field);
|
|
57
53
|
const sign = effectiveDirection(field, options.direction) === naturalDirection(field) ? 1 : -1;
|
|
58
54
|
const base = field === "alpha" ? byAlpha : field === "created" ? byCreated : byUpdated;
|
|
59
|
-
return (a, b) =>
|
|
60
|
-
if (field === "updated" && options.workingFirst) {
|
|
61
|
-
const aActive = a.thread.status === "active";
|
|
62
|
-
const bActive = b.thread.status === "active";
|
|
63
|
-
if (aActive !== bActive) return aActive ? -1 : 1;
|
|
64
|
-
}
|
|
65
|
-
return sign * base(a, b);
|
|
66
|
-
};
|
|
55
|
+
return (a, b) => sign * base(a, b);
|
|
67
56
|
}
|
|
68
57
|
|
|
69
58
|
/** Pinned order: pinSortKey, then pinnedAt desc, createdAt desc, id. */
|