@gperezmz/bb-plugin-thread-glance 0.2.3 → 0.3.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 +2 -1
- 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 +8 -50
- 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/{Toolbar.tsx → SettingsPanel.tsx} +26 -57
- package/features/thread-list/components/ThreadDetails.tsx +3 -3
- package/features/thread-list/components/ThreadList.tsx +116 -84
- package/features/thread-list/components/ThreadRowView.tsx +70 -78
- package/features/thread-list/components/controller.ts +5 -10
- package/features/thread-list/components/glyphs.tsx +39 -14
- package/features/thread-list/data/same-window.ts +34 -0
- package/features/thread-list/data/useAutoExpand.ts +1 -1
- package/features/thread-list/data/useClientPreferences.ts +15 -7
- package/features/thread-list/data/useDefaultBranches.ts +35 -0
- package/features/thread-list/data/usePreferences.ts +16 -7
- 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 -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 +28 -17
- package/features/thread-list/model/layout.ts +5 -2
- 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 +2 -18
- 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 +25 -9
- 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 +200 -274
- package/features/thread-list/testing/fixtures.ts +41 -12
- package/package.json +1 -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
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Accessible names: everything a tooltip says is in the aria-label.
|
|
2
|
-
import type { OlderRow, ThreadRow } from "./view";
|
|
2
|
+
import type { OlderRow, SettledRow, ThreadRow } from "./view";
|
|
3
3
|
import { FLAG_GLYPHS, 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;
|
|
@@ -19,33 +20,43 @@ export function rowAriaLabel(
|
|
|
19
20
|
): string {
|
|
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}`);
|
|
23
|
+
if (row.childDot !== null) parts.push(`child threads: ${FLAG_GLYPHS[row.childDot].label}`);
|
|
22
24
|
if (row.crossGroupLabel !== null) parts.push(row.crossGroupLabel.toLowerCase());
|
|
23
|
-
if (row.homeGroupLabel !== null) parts.push(`in ${row.homeGroupLabel}`);
|
|
24
25
|
if (row.hiddenBadge) parts.push("hidden thread");
|
|
25
26
|
if (row.info.unread && row.info.state.kind !== "unread") parts.push("unread");
|
|
26
27
|
if (options.hasDraft && row.info.state.kind !== "draft") parts.push("unsubmitted draft");
|
|
27
28
|
return `Open ${row.info.thread.displayTitle} — ${parts.join("; ")}`;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
export function chipLabel(title: string, count: number,
|
|
31
|
+
export function chipLabel(title: string, count: number, expanded: boolean): string {
|
|
31
32
|
const noun = count === 1 ? "child thread" : "child threads";
|
|
32
|
-
|
|
33
|
-
return `${expanded ? "Collapse" : "Show"} ${count} ${noun} of ${title}${detail}`;
|
|
33
|
+
return `${expanded ? "Collapse" : "Show"} ${count} ${noun} of ${title}`;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
* The fold row's text and accessible name. The family's row says "child
|
|
38
|
-
* threads" so it never reads like the group's "older" row.
|
|
39
|
-
*/
|
|
36
|
+
/** The text and accessible name of an open tree's fold row. */
|
|
40
37
|
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
38
|
const children = row.count === 1 ? "child thread" : "child threads";
|
|
49
|
-
if (row.expanded) return { label: "Show fewer", ariaLabel: `Hide ${row.count}
|
|
39
|
+
if (row.expanded) return { label: "Show fewer", ariaLabel: `Hide ${row.count} more ${children}` };
|
|
50
40
|
return { label: `${row.count} more ${children}`, ariaLabel: `Show ${row.count} more ${children}` };
|
|
51
41
|
}
|
|
42
|
+
|
|
43
|
+
/** The settled fold's text: "Settled (N)" while closed, "Settled" while open. N counts trees. */
|
|
44
|
+
export function settledRowText(row: SettledRow): { label: string; ariaLabel: string } {
|
|
45
|
+
const noun = row.count === 1 ? "settled thread tree" : "settled thread trees";
|
|
46
|
+
return {
|
|
47
|
+
label: row.expanded ? "Settled" : `Settled (${row.count})`,
|
|
48
|
+
ariaLabel: `${row.expanded ? "Hide" : "Show"} ${row.count} ${noun}`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The list header's name for the current grouping. */
|
|
53
|
+
export function groupingName(mode: OrganizationMode): string {
|
|
54
|
+
switch (mode) {
|
|
55
|
+
case "project":
|
|
56
|
+
return "Projects";
|
|
57
|
+
case "chronological":
|
|
58
|
+
return "Sections";
|
|
59
|
+
case "machine":
|
|
60
|
+
return "Machines";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// How far a row sits from the list's left edge. Pure, so a test can
|
|
2
2
|
// call it without rendering.
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Left padding of a root row: its Status column starts where the group
|
|
5
|
+
* header's text does.
|
|
6
|
+
*/
|
|
4
7
|
export const ROOT_INDENT = 8;
|
|
5
8
|
/**
|
|
6
|
-
* A child's
|
|
9
|
+
* A child's Status column starts this far right of its parent's, so the
|
|
7
10
|
* child's title sits this far right of the parent's title.
|
|
8
11
|
*/
|
|
9
12
|
export const FOLDED_STEP = 12;
|
|
@@ -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
|
-
// What the settings
|
|
2
|
-
import type { ChildAttention,
|
|
1
|
+
// What the settings panel's controls show and write. Pure.
|
|
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. */
|
|
@@ -48,9 +48,12 @@ const NEEDS: Record<NeedsKind, { icon: string; label: string }> = {
|
|
|
48
48
|
input: { icon: "MessageQuestion", label: "Needs your input" },
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* How a glyph is drawn. `dot` is the unread dot, `ring` the Idle state's
|
|
53
|
+
* faint ring; everything else a host icon.
|
|
54
|
+
*/
|
|
52
55
|
export interface Glyph {
|
|
53
|
-
icon: string | "dot" |
|
|
56
|
+
icon: string | "dot" | "ring";
|
|
54
57
|
tone: Tone;
|
|
55
58
|
spin: boolean;
|
|
56
59
|
shine: boolean;
|
|
@@ -231,7 +234,7 @@ export function computeState(thread: PluginSidebarThread, inputs: StateInputs):
|
|
|
231
234
|
}
|
|
232
235
|
if (inputs.unread) return { kind: "unread", label: "Unread", glyph: glyph("dot", "none"), ...none };
|
|
233
236
|
if (inputs.hasDraft) return { kind: "draft", label: "Unsubmitted draft", glyph: glyph("Edit", "muted"), ...none };
|
|
234
|
-
return { kind: "idle", label: "Idle", glyph: glyph(
|
|
237
|
+
return { kind: "idle", label: "Idle", glyph: glyph("ring", "muted"), ...none };
|
|
235
238
|
}
|
|
236
239
|
|
|
237
240
|
/**
|
|
@@ -268,26 +271,39 @@ export function hiddenThreadFlags(flags: ReadonlySet<Flag>): Set<Flag> {
|
|
|
268
271
|
return kept;
|
|
269
272
|
}
|
|
270
273
|
|
|
274
|
+
/** Flags a hidden thread adds to its parent's child dot: what asks for you or failed, and offline. */
|
|
275
|
+
const HIDDEN_DOT_FLAGS: ReadonlySet<Flag> = new Set<Flag>(["waits-on-you", "unread-failed", "queue-failed", "offline"]);
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The flags a thread adds to the child dot of the threads above it. A
|
|
279
|
+
* hidden thread adds only waits-on-you, failed, queued message failed and
|
|
280
|
+
* offline; which trees need attention reads `hiddenThreadFlags` instead,
|
|
281
|
+
* so this leaves it unchanged.
|
|
282
|
+
*/
|
|
283
|
+
export function dotFlagsOf(flags: ReadonlySet<Flag>, isHidden: boolean): Set<Flag> {
|
|
284
|
+
return isHidden ? new Set([...flags].filter((flag) => HIDDEN_DOT_FLAGS.has(flag))) : new Set(flags);
|
|
285
|
+
}
|
|
286
|
+
|
|
271
287
|
/** The most urgent flag, or null. */
|
|
272
288
|
export function mostUrgent(flags: ReadonlySet<Flag>): Flag | null {
|
|
273
289
|
return FLAG_ORDER.find((flag) => flags.has(flag)) ?? null;
|
|
274
290
|
}
|
|
275
291
|
|
|
276
|
-
/** The colour a child
|
|
277
|
-
export type
|
|
292
|
+
/** The colour of a child dot, by the most urgent state among the descendants. */
|
|
293
|
+
export type DotTone = "attention" | "destructive" | "working" | "unread";
|
|
278
294
|
|
|
279
|
-
|
|
280
|
-
export function chipTone(flag: Flag | null): ChipTone {
|
|
295
|
+
export function dotTone(flag: Flag): DotTone {
|
|
281
296
|
switch (flag) {
|
|
282
297
|
case "waits-on-you":
|
|
298
|
+
case "offline":
|
|
283
299
|
return "attention";
|
|
284
300
|
case "unread-failed":
|
|
285
301
|
case "queue-failed":
|
|
286
302
|
return "destructive";
|
|
287
303
|
case "working":
|
|
288
304
|
return "working";
|
|
289
|
-
|
|
290
|
-
return "
|
|
305
|
+
case "unread":
|
|
306
|
+
return "unread";
|
|
291
307
|
}
|
|
292
308
|
}
|
|
293
309
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// What a user's click on a chip, fold or header changes.
|
|
2
2
|
// Pure: returns the preference patch and which auto-expand targets to drop.
|
|
3
3
|
import type { Preferences } from "@/shared/preferences";
|
|
4
|
-
import { ancestorsOf, type
|
|
4
|
+
import { ancestorsOf, type ThreadTree, type Forest } from "./trees";
|
|
5
5
|
import { isGroupCollapsed, toggleGroupCollapse } from "./groups";
|
|
6
6
|
import { isDoneUnseen } from "./state";
|
|
7
|
-
import type { GroupView, OlderRow, ThreadRow } from "./view";
|
|
7
|
+
import type { GroupView, OlderRow, SettledRow, ThreadRow } from "./view";
|
|
8
8
|
|
|
9
9
|
export interface ToggleOutcome {
|
|
10
10
|
patch: Partial<Preferences>;
|
|
@@ -29,36 +29,31 @@ export function toggleChip(row: ThreadRow, prefs: Preferences, forest: Forest):
|
|
|
29
29
|
: openChildren(id, prefs);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/** Opens a parent's children, as its chip does
|
|
32
|
+
/** Opens a parent's children, as its chip does, from an auto-reveal's fold. */
|
|
33
33
|
export function openChildren(parentId: string, prefs: Preferences): ToggleOutcome {
|
|
34
34
|
return { patch: { expandedChildren: [...without(prefs.expandedChildren, parentId), parentId] }, drop: null };
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
/** Opens or closes an open tree's "N more child threads" fold. */
|
|
38
|
+
export function toggleOlder(row: OlderRow, prefs: Preferences, forest: Forest): ToggleOutcome {
|
|
38
39
|
if (row.scope === "reveal") return openChildren(row.scopeId, prefs);
|
|
39
40
|
if (!row.expanded) {
|
|
40
41
|
return { patch: { expandedOlder: [...without(prefs.expandedOlder, row.scopeId), row.scopeId] }, drop: null };
|
|
41
42
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
return {
|
|
49
|
-
patch,
|
|
50
|
-
drop: (id) => {
|
|
51
|
-
const family = forest.familyOf.get(id);
|
|
52
|
-
return family !== undefined && quietRoots.has(family.root.thread.id) && inGroup.has(family.root.thread.id);
|
|
53
|
-
},
|
|
54
|
-
};
|
|
43
|
+
return { patch: { expandedOlder: without(prefs.expandedOlder, row.scopeId) }, drop: under(forest, row.scopeId) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Opens or closes a group's settled fold. */
|
|
47
|
+
export function toggleSettled(row: SettledRow, prefs: Preferences): ToggleOutcome {
|
|
48
|
+
const rest = without(prefs.openSettledFolds, row.groupId);
|
|
49
|
+
return { patch: { openSettledFolds: row.expanded ? rest : [...rest, row.groupId] }, drop: null };
|
|
55
50
|
}
|
|
56
51
|
|
|
57
52
|
export function toggleGroup(group: GroupView, prefs: Preferences, forest: Forest): ToggleOutcome {
|
|
58
53
|
const inGroup = new Set(group.rootIds);
|
|
59
54
|
const dropGroup = (id: string) => {
|
|
60
|
-
const
|
|
61
|
-
return
|
|
55
|
+
const tree = forest.treeOf.get(id);
|
|
56
|
+
return tree !== undefined && inGroup.has(tree.root.thread.id);
|
|
62
57
|
};
|
|
63
58
|
if (group.collapsed) {
|
|
64
59
|
return {
|
|
@@ -78,19 +73,35 @@ export interface MarkAllRead {
|
|
|
78
73
|
seen: string[];
|
|
79
74
|
}
|
|
80
75
|
|
|
81
|
-
/** Every unread thread in the
|
|
76
|
+
/** Every unread thread in the trees, descendants included: Mark all read, and a root's Mark read for its tree. */
|
|
82
77
|
export function markAllReadPlan(
|
|
83
|
-
|
|
78
|
+
trees: readonly ThreadTree[],
|
|
84
79
|
context: { activeThreadId: string | null; finishedAt: Readonly<Record<string, number>>; seenAt: Readonly<Record<string, number>> },
|
|
85
80
|
): MarkAllRead {
|
|
86
81
|
const read: string[] = [];
|
|
87
82
|
const seen: string[] = [];
|
|
88
|
-
for (const
|
|
89
|
-
for (const info of [
|
|
90
|
-
if (!info.unread
|
|
83
|
+
for (const tree of trees) {
|
|
84
|
+
for (const info of [tree.root, ...tree.descendants]) {
|
|
85
|
+
if (!info.unread) continue;
|
|
91
86
|
read.push(info.thread.id);
|
|
92
87
|
if (isDoneUnseen(info.thread, context)) seen.push(info.thread.id);
|
|
93
88
|
}
|
|
94
89
|
}
|
|
95
90
|
return { read, seen };
|
|
96
91
|
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* What one row's Mark read marks: on a tree's root, every unread thread in
|
|
95
|
+
* the tree; on any other row, the thread alone.
|
|
96
|
+
*/
|
|
97
|
+
export function markReadPlanFor(
|
|
98
|
+
threadId: string,
|
|
99
|
+
forest: Pick<Forest, "infos" | "treeOf">,
|
|
100
|
+
context: { activeThreadId: string | null; finishedAt: Readonly<Record<string, number>>; seenAt: Readonly<Record<string, number>> },
|
|
101
|
+
): MarkAllRead {
|
|
102
|
+
const tree = forest.treeOf.get(threadId);
|
|
103
|
+
if (tree !== undefined && tree.root.thread.id === threadId) return markAllReadPlan([tree], context);
|
|
104
|
+
const thread = forest.infos.get(threadId)?.thread;
|
|
105
|
+
if (thread === undefined) return { read: [threadId], seen: [] };
|
|
106
|
+
return { read: [threadId], seen: isDoneUnseen(thread, context) ? [threadId] : [] };
|
|
107
|
+
}
|