@gperezmz/bb-plugin-thread-glance 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +20 -0
  3. package/THIRD_PARTY_NOTICES.md +230 -0
  4. package/app.tsx +12 -0
  5. package/components/ui/alert-dialog.tsx +142 -0
  6. package/components/ui/button.tsx +59 -0
  7. package/components/ui/card.tsx +83 -0
  8. package/components/ui/coarse-pointer-sizing.ts +66 -0
  9. package/components/ui/context-menu.tsx +355 -0
  10. package/components/ui/dialog.tsx +416 -0
  11. package/components/ui/dropdown-menu.tsx +640 -0
  12. package/components/ui/hooks/use-compact-viewport.tsx +37 -0
  13. package/components/ui/hooks/use-media-query.ts +70 -0
  14. package/components/ui/hooks/use-pointer-coarse.ts +7 -0
  15. package/components/ui/hover-card.tsx +36 -0
  16. package/components/ui/icon-extended.tsx +333 -0
  17. package/components/ui/icon-registry.ts +189 -0
  18. package/components/ui/icon.tsx +444 -0
  19. package/components/ui/input.tsx +30 -0
  20. package/components/ui/menu-item-hover.tsx +93 -0
  21. package/components/ui/motion.ts +4 -0
  22. package/components/ui/overlay-trigger.ts +84 -0
  23. package/components/ui/popover.tsx +223 -0
  24. package/components/ui/responsive-overlay.tsx +823 -0
  25. package/components/ui/tooltip.tsx +63 -0
  26. package/dist/app.css +2 -0
  27. package/dist/app.js +66 -0
  28. package/dist/app.meta.json +11 -0
  29. package/dist/server.js +71 -0
  30. package/dist/server.js.map +6 -0
  31. package/dist/server.meta.json +11 -0
  32. package/features/thread-list/components/Dialogs.tsx +299 -0
  33. package/features/thread-list/components/FoldRows.tsx +147 -0
  34. package/features/thread-list/components/GroupSection.tsx +370 -0
  35. package/features/thread-list/components/ProviderBadge.tsx +57 -0
  36. package/features/thread-list/components/PullRequestBadge.tsx +50 -0
  37. package/features/thread-list/components/Rails.tsx +28 -0
  38. package/features/thread-list/components/RenameEditor.tsx +118 -0
  39. package/features/thread-list/components/RowMenu.tsx +168 -0
  40. package/features/thread-list/components/SplitMiniMap.tsx +55 -0
  41. package/features/thread-list/components/ThreadDetails.tsx +138 -0
  42. package/features/thread-list/components/ThreadList.tsx +855 -0
  43. package/features/thread-list/components/ThreadRowView.tsx +631 -0
  44. package/features/thread-list/components/Toolbar.tsx +296 -0
  45. package/features/thread-list/components/controller.ts +64 -0
  46. package/features/thread-list/components/glyphs.tsx +129 -0
  47. package/features/thread-list/components/row-card.ts +127 -0
  48. package/features/thread-list/data/storage.ts +18 -0
  49. package/features/thread-list/data/useAutoExpand.ts +53 -0
  50. package/features/thread-list/data/useClientPreferences.ts +20 -0
  51. package/features/thread-list/data/useNotes.ts +41 -0
  52. package/features/thread-list/data/useNow.ts +19 -0
  53. package/features/thread-list/data/usePreferences.ts +126 -0
  54. package/features/thread-list/data/useScheduled.ts +43 -0
  55. package/features/thread-list/data/useStamps.ts +68 -0
  56. package/features/thread-list/icons.ts +44 -0
  57. package/features/thread-list/index.ts +2 -0
  58. package/features/thread-list/model/attention.ts +89 -0
  59. package/features/thread-list/model/counters.ts +67 -0
  60. package/features/thread-list/model/details.ts +79 -0
  61. package/features/thread-list/model/drag.ts +104 -0
  62. package/features/thread-list/model/expansion.ts +71 -0
  63. package/features/thread-list/model/families.ts +297 -0
  64. package/features/thread-list/model/groups.ts +263 -0
  65. package/features/thread-list/model/labels.ts +50 -0
  66. package/features/thread-list/model/layout.ts +61 -0
  67. package/features/thread-list/model/menu.ts +78 -0
  68. package/features/thread-list/model/move.ts +46 -0
  69. package/features/thread-list/model/notes.ts +52 -0
  70. package/features/thread-list/model/provider-mark.ts +59 -0
  71. package/features/thread-list/model/share.ts +101 -0
  72. package/features/thread-list/model/sort.ts +78 -0
  73. package/features/thread-list/model/state.ts +330 -0
  74. package/features/thread-list/model/time.ts +107 -0
  75. package/features/thread-list/model/toggles.ts +99 -0
  76. package/features/thread-list/model/view.ts +669 -0
  77. package/features/thread-list/model/windowing.ts +27 -0
  78. package/features/thread-list/testing/fixtures.ts +162 -0
  79. package/hooks/useBrowserDimmingModal.ts +5 -0
  80. package/lib/portal-scope.ts +15 -0
  81. package/lib/utils.ts +17 -0
  82. package/package.json +96 -0
  83. package/server/cli.ts +107 -0
  84. package/server/failures.ts +136 -0
  85. package/server/import.ts +144 -0
  86. package/server/notes.ts +236 -0
  87. package/server/preference-store.ts +96 -0
  88. package/server/scheduled.ts +129 -0
  89. package/server/serial.ts +12 -0
  90. package/server/stamps.ts +142 -0
  91. package/server/startup.ts +121 -0
  92. package/server.ts +126 -0
  93. package/shared/contract.ts +73 -0
  94. package/shared/preferences.ts +241 -0
  95. package/shared/signals.ts +71 -0
  96. package/skills/thread-glance/SKILL.md +69 -0
  97. package/tsconfig.json +36 -0
  98. package/types/global-jsx.d.ts +10 -0
@@ -0,0 +1,263 @@
1
+ // Top-level groups: which group a family lands in, and the order
2
+ // groups appear in. Pure.
3
+ import type {
4
+ PluginSidebarProject,
5
+ PluginSidebarSection,
6
+ PluginSidebarThread,
7
+ } from "@get-bb/plugin-sdk/app";
8
+ import type { OrganizationMode, Preferences } from "@/shared/preferences";
9
+
10
+ export type GroupKind = "pinned" | "threads" | "project" | "section" | "machine";
11
+
12
+ export interface GroupDescriptor {
13
+ /** `pinned`, `threads`, `project:<id>`, `section:<id>` or `machine:<id>`. */
14
+ id: string;
15
+ kind: GroupKind;
16
+ label: string;
17
+ /** Project, section or machine id for entity groups. */
18
+ entityId: string | null;
19
+ /** Project a new thread from this group's `+` goes to. */
20
+ newThreadProjectId: string | null;
21
+ newThreadSectionId: string | null;
22
+ }
23
+
24
+ export interface GroupingInputs {
25
+ mode: OrganizationMode;
26
+ projects: readonly PluginSidebarProject[];
27
+ sections: readonly PluginSidebarSection[];
28
+ threads: readonly PluginSidebarThread[];
29
+ }
30
+
31
+ export const PINNED_GROUP_ID = "pinned";
32
+ export const THREADS_GROUP_ID = "threads";
33
+
34
+ function isPinnedThread(thread: PluginSidebarThread): boolean {
35
+ return thread.pinnedAt !== null || thread.isPinned;
36
+ }
37
+
38
+ /** The personal project the loose Threads bucket stands for in project mode. */
39
+ export function personalProject(
40
+ projects: readonly PluginSidebarProject[],
41
+ ): PluginSidebarProject | null {
42
+ return projects.find((project) => project.isPersonal) ?? null;
43
+ }
44
+
45
+ /**
46
+ * The group a family root belongs to. Pinned roots go to Pinned. A root
47
+ * whose project, section or machine isn't known goes to the loose Threads
48
+ * bucket, except an unknown section, which gets a placeholder group as in
49
+ * bb.
50
+ */
51
+ export function groupIdForRoot(
52
+ root: PluginSidebarThread,
53
+ inputs: Pick<GroupingInputs, "mode" | "projects">,
54
+ ): string {
55
+ if (isPinnedThread(root) && !root.isHidden) return PINNED_GROUP_ID;
56
+ switch (inputs.mode) {
57
+ case "project": {
58
+ const project = inputs.projects.find((candidate) => candidate.id === root.projectId);
59
+ if (project === undefined || project.isPersonal) return THREADS_GROUP_ID;
60
+ return `project:${project.id}`;
61
+ }
62
+ case "chronological":
63
+ return root.sectionId === null ? THREADS_GROUP_ID : `section:${root.sectionId}`;
64
+ case "machine":
65
+ return root.host === null ? THREADS_GROUP_ID : `machine:${root.host.id}`;
66
+ }
67
+ }
68
+
69
+ /** Entity groups for the mode, in their natural order. */
70
+ export function entityGroups(
71
+ inputs: GroupingInputs,
72
+ alphabetical: boolean,
73
+ ): GroupDescriptor[] {
74
+ const personal = personalProject(inputs.projects);
75
+ switch (inputs.mode) {
76
+ case "project":
77
+ return inputs.projects
78
+ .filter((project) => !project.isPersonal)
79
+ .map((project) => ({
80
+ id: `project:${project.id}`,
81
+ kind: "project" as const,
82
+ label: project.name,
83
+ entityId: project.id,
84
+ newThreadProjectId: project.id,
85
+ newThreadSectionId: null,
86
+ }));
87
+ case "chronological": {
88
+ const known = new Set(inputs.sections.map((section) => section.id));
89
+ const sections = [...inputs.sections];
90
+ if (alphabetical) sections.sort((a, b) => a.name.localeCompare(b.name));
91
+ const groups: GroupDescriptor[] = sections.map((section) => ({
92
+ id: `section:${section.id}`,
93
+ kind: "section",
94
+ label: section.name,
95
+ entityId: section.id,
96
+ newThreadProjectId: personal?.id ?? null,
97
+ newThreadSectionId: section.id,
98
+ }));
99
+ for (const thread of inputs.threads) {
100
+ if (thread.sectionId === null || known.has(thread.sectionId)) continue;
101
+ known.add(thread.sectionId);
102
+ groups.push({
103
+ id: `section:${thread.sectionId}`,
104
+ kind: "section",
105
+ label: "Section",
106
+ entityId: thread.sectionId,
107
+ newThreadProjectId: personal?.id ?? null,
108
+ newThreadSectionId: thread.sectionId,
109
+ });
110
+ }
111
+ return groups;
112
+ }
113
+ case "machine": {
114
+ const seen = new Map<string, string>();
115
+ for (const thread of inputs.threads) {
116
+ if (thread.host !== null && !seen.has(thread.host.id)) {
117
+ seen.set(thread.host.id, thread.host.name);
118
+ }
119
+ }
120
+ return [...seen].map(([id, name]) => ({
121
+ id: `machine:${id}`,
122
+ kind: "machine" as const,
123
+ label: name || "Unknown machine",
124
+ entityId: id,
125
+ newThreadProjectId: null,
126
+ newThreadSectionId: null,
127
+ }));
128
+ }
129
+ }
130
+ }
131
+
132
+ export function builtinGroup(
133
+ id: typeof PINNED_GROUP_ID | typeof THREADS_GROUP_ID,
134
+ projects: readonly PluginSidebarProject[],
135
+ ): GroupDescriptor {
136
+ if (id === PINNED_GROUP_ID) {
137
+ return {
138
+ id,
139
+ kind: "pinned",
140
+ label: "Pinned",
141
+ entityId: null,
142
+ newThreadProjectId: null,
143
+ newThreadSectionId: null,
144
+ };
145
+ }
146
+ return {
147
+ id,
148
+ kind: "threads",
149
+ label: "Threads",
150
+ entityId: null,
151
+ newThreadProjectId: personalProject(projects)?.id ?? null,
152
+ newThreadSectionId: null,
153
+ };
154
+ }
155
+
156
+ const ANCHORS: Record<OrganizationMode, string> = {
157
+ project: "projects",
158
+ chronological: "sections",
159
+ machine: "machines",
160
+ };
161
+
162
+ export const ORDER_PREFERENCE: Record<
163
+ OrganizationMode,
164
+ "sectionOrder" | "manualSectionOrder" | "machineSectionOrder"
165
+ > = {
166
+ project: "sectionOrder",
167
+ chronological: "manualSectionOrder",
168
+ machine: "machineSectionOrder",
169
+ };
170
+
171
+ /**
172
+ * Resolves the stored top-level order for a mode: the legacy anchor
173
+ * (`projects`, `sections`, `machines`) expands to the entity ids, unknown
174
+ * ids are dropped, and entities missing from the stored order are spliced
175
+ * in after the last entity group. Pinned and Threads are always present.
176
+ */
177
+ export function resolveGroupOrder(
178
+ mode: OrganizationMode,
179
+ stored: readonly string[],
180
+ entityIds: readonly string[],
181
+ ): string[] {
182
+ const anchor = ANCHORS[mode];
183
+ const entities = new Set(entityIds);
184
+ const explicit = new Set(stored);
185
+ const out: string[] = [];
186
+ const push = (id: string) => {
187
+ if (!out.includes(id)) out.push(id);
188
+ };
189
+ for (const id of stored) {
190
+ if (id === anchor) {
191
+ for (const entity of entityIds) if (!explicit.has(entity)) push(entity);
192
+ } else if (id === PINNED_GROUP_ID || id === THREADS_GROUP_ID || entities.has(id)) {
193
+ push(id);
194
+ }
195
+ }
196
+ const missing = entityIds.filter((id) => !out.includes(id));
197
+ if (missing.length > 0) {
198
+ let lastEntity = -1;
199
+ out.forEach((id, index) => {
200
+ if (entities.has(id)) lastEntity = index;
201
+ });
202
+ const insertAt =
203
+ lastEntity >= 0
204
+ ? lastEntity + 1
205
+ : out.includes(THREADS_GROUP_ID)
206
+ ? out.indexOf(THREADS_GROUP_ID)
207
+ : out.length;
208
+ out.splice(insertAt, 0, ...missing);
209
+ }
210
+ if (!out.includes(PINNED_GROUP_ID)) out.unshift(PINNED_GROUP_ID);
211
+ if (!out.includes(THREADS_GROUP_ID)) out.push(THREADS_GROUP_ID);
212
+ return out;
213
+ }
214
+
215
+ /** Moves `id` before or after `targetId` in an order (header drag). */
216
+ export function moveGroup(
217
+ order: readonly string[],
218
+ id: string,
219
+ targetId: string,
220
+ placement: "before" | "after",
221
+ ): string[] {
222
+ if (id === targetId) return [...order];
223
+ const without = order.filter((candidate) => candidate !== id);
224
+ const index = without.indexOf(targetId);
225
+ if (index < 0) return [...order];
226
+ without.splice(placement === "before" ? index : index + 1, 0, id);
227
+ return without;
228
+ }
229
+
230
+ /** The collapse state of a group, from bb's per-kind collapsed sets. */
231
+ export function isGroupCollapsed(group: GroupDescriptor, prefs: Preferences): boolean {
232
+ switch (group.kind) {
233
+ case "pinned":
234
+ case "threads":
235
+ return (prefs.collapsedSections as readonly string[]).includes(group.kind);
236
+ case "project":
237
+ return prefs.collapsedProjects.includes(group.entityId!);
238
+ case "section":
239
+ return prefs.collapsedThreadSections.includes(group.id);
240
+ case "machine":
241
+ return prefs.collapsedMachines.includes(group.entityId!);
242
+ }
243
+ }
244
+
245
+ /** The preference change that toggles a group's collapse. */
246
+ export function toggleGroupCollapse(
247
+ group: GroupDescriptor,
248
+ prefs: Preferences,
249
+ ): Partial<Preferences> {
250
+ const toggle = <T extends string>(list: readonly T[], value: T): T[] =>
251
+ list.includes(value) ? list.filter((item) => item !== value) : [...list, value];
252
+ switch (group.kind) {
253
+ case "pinned":
254
+ case "threads":
255
+ return { collapsedSections: toggle(prefs.collapsedSections, group.kind) };
256
+ case "project":
257
+ return { collapsedProjects: toggle(prefs.collapsedProjects, group.entityId!) };
258
+ case "section":
259
+ return { collapsedThreadSections: toggle(prefs.collapsedThreadSections, group.id) };
260
+ case "machine":
261
+ return { collapsedMachines: toggle(prefs.collapsedMachines, group.entityId!) };
262
+ }
263
+ }
@@ -0,0 +1,50 @@
1
+ // Accessible names: everything a tooltip says is in the aria-label.
2
+ import type { OlderRow, ThreadRow } from "./view";
3
+ import { FLAG_GLYPHS, type Flag } from "./state";
4
+ import { formatDateTime } from "./details";
5
+
6
+ export function stateText(row: ThreadRow, pluginLabel: string | null): string {
7
+ const state = row.info.state;
8
+ const base = pluginLabel ?? state.label;
9
+ if (state.kind === "scheduled" && state.sendAt !== null) {
10
+ return `${base}, sends ${formatDateTime(state.sendAt)}`;
11
+ }
12
+ return base;
13
+ }
14
+
15
+ /** "Open Fix login — Working; Claude Code; child of Manager; unread". */
16
+ export function rowAriaLabel(
17
+ row: ThreadRow,
18
+ options: { providerName: string; pluginLabel: string | null; hasDraft: boolean },
19
+ ): string {
20
+ const parts = [stateText(row, options.pluginLabel), options.providerName];
21
+ if (row.parentTitle !== null && row.depth > 0) parts.push(`child of ${row.parentTitle}`);
22
+ if (row.crossGroupLabel !== null) parts.push(row.crossGroupLabel.toLowerCase());
23
+ if (row.hiddenBadge) parts.push("hidden thread");
24
+ if (row.info.unread && row.info.state.kind !== "unread") parts.push("unread");
25
+ if (options.hasDraft && row.info.state.kind !== "draft") parts.push("unsubmitted draft");
26
+ return `Open ${row.info.thread.displayTitle} — ${parts.join("; ")}`;
27
+ }
28
+
29
+ export function chipLabel(title: string, count: number, flag: Flag | null, expanded: boolean): string {
30
+ const noun = count === 1 ? "child thread" : "child threads";
31
+ const detail = flag === null ? "" : `, ${FLAG_GLYPHS[flag].label}`;
32
+ return `${expanded ? "Collapse" : "Show"} ${count} ${noun} of ${title}${detail}`;
33
+ }
34
+
35
+ /**
36
+ * The fold row's text and accessible name. The family's row says "child
37
+ * threads" so it never reads like the group's "older" row.
38
+ */
39
+ export function olderRowText(row: OlderRow): { label: string; ariaLabel: string } {
40
+ const noun = row.count === 1 ? "thread" : "threads";
41
+ if (row.scope === "group") {
42
+ return {
43
+ label: row.expanded ? "Show fewer" : `${row.count} older`,
44
+ ariaLabel: `${row.expanded ? "Hide" : "Show"} ${row.count} older ${noun}`,
45
+ };
46
+ }
47
+ const children = row.count === 1 ? "child thread" : "child threads";
48
+ if (row.expanded) return { label: "Show fewer", ariaLabel: `Hide ${row.count} older ${children}` };
49
+ return { label: `${row.count} more ${children}`, ariaLabel: `Show ${row.count} more ${children}` };
50
+ }
@@ -0,0 +1,61 @@
1
+ // How far a row sits from the list's left edge. Pure, so a test can
2
+ // call it without rendering.
3
+ import type { Nesting } from "@/shared/preferences";
4
+
5
+ /** Left padding of a root row, as in bb's list. */
6
+ export const ROOT_INDENT = 8;
7
+ /** Tree nesting keeps bb's step per level. */
8
+ export const TREE_STEP = 24;
9
+ /**
10
+ * Folded nesting shows one flat level. A child's status slot starts this far
11
+ * right of its parent's, so the child's title sits this far right of the
12
+ * parent's title.
13
+ */
14
+ export const FOLDED_STEP = 12;
15
+
16
+ export function rowIndent(depth: number, nesting: Nesting): number {
17
+ return ROOT_INDENT + depth * (nesting === "tree" ? TREE_STEP : FOLDED_STEP);
18
+ }
19
+
20
+ /** How far the rail sits from a row's left edge: the centre of the status slot at that level. */
21
+ export function railLeft(level: number, nesting: Nesting): number {
22
+ return rowIndent(level, nesting) + 8;
23
+ }
24
+
25
+ /**
26
+ * One level of guide rail on one row. `start` begins under the row's
27
+ * status slot, `full` runs the row's height, `end` stops at its middle.
28
+ */
29
+ export type Rail = "start" | "full" | "end";
30
+
31
+ /**
32
+ * The rails every row draws, from the depths of the rows in order. Level `k`
33
+ * runs from the row at depth `k` through the rows below it, and stops at the
34
+ * last of them, so a family's rail ends on its last row, the fold row
35
+ * included. Entry `k` of a row's result is its rail at level `k`, or null.
36
+ */
37
+ export function railsFor(depths: readonly number[]): (Rail | null)[][] {
38
+ return depths.map((depth, index) => {
39
+ const next = depths[index + 1];
40
+ const rails: (Rail | null)[] = [];
41
+ for (let level = 0; level <= depth; level += 1) {
42
+ const continues = next !== undefined && next > level;
43
+ if (level === depth) rails.push(continues ? "start" : null);
44
+ else rails.push(continues ? "full" : "end");
45
+ }
46
+ return rails;
47
+ });
48
+ }
49
+
50
+ /**
51
+ * How a row's title reads. Children step back from roots: a size smaller and
52
+ * muted, unless the thread is unread or open. Hover restores the colour in
53
+ * the component.
54
+ */
55
+ export function titleTreatment(
56
+ depth: number,
57
+ state: { unread: boolean; active: boolean },
58
+ ): { small: boolean; muted: boolean } {
59
+ const child = depth > 0;
60
+ return { small: child, muted: child && !state.unread && !state.active };
61
+ }
@@ -0,0 +1,78 @@
1
+ // Row menu items in bb's order, plus Thread Glance's additions. Pure:
2
+ // the menu components render this list.
3
+ import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
4
+ import { ICONS } from "../icons";
5
+
6
+ export type RowMenuAction =
7
+ | "open-in-split"
8
+ | "copy-link"
9
+ | "copy-id"
10
+ | "mark-read"
11
+ | "mark-unread"
12
+ | "pin"
13
+ | "unpin"
14
+ | "move-to-section"
15
+ | "move"
16
+ | "rename"
17
+ | "details"
18
+ | "archive"
19
+ | "unarchive"
20
+ | "delete";
21
+
22
+ export interface RowMenuItem {
23
+ action: RowMenuAction;
24
+ label: string;
25
+ icon: string;
26
+ destructive?: boolean;
27
+ /** Draw a separator before this item. */
28
+ separated?: boolean;
29
+ }
30
+
31
+ export interface RowMenuInputs {
32
+ thread: PluginSidebarThread;
33
+ unread: boolean;
34
+ splitAvailable: boolean;
35
+ /** True for a thread with no parent (bb: Move to section on roots only). */
36
+ isRoot: boolean;
37
+ hasSections: boolean;
38
+ /** Kept for callers; Details is on every viewport. */
39
+ compact: boolean;
40
+ }
41
+
42
+ export function rowMenuItems(inputs: RowMenuInputs): RowMenuItem[] {
43
+ const { thread } = inputs;
44
+ const archived = thread.archivedAt !== null || thread.isArchived;
45
+ const pinned = thread.pinnedAt !== null || thread.isPinned;
46
+ const items: RowMenuItem[] = [];
47
+ items.push({ action: "details", label: "Details", icon: ICONS.details });
48
+ if (inputs.splitAvailable) {
49
+ items.push({ action: "open-in-split", label: "Open in split", icon: ICONS.openInSplit });
50
+ }
51
+ items.push({ action: "copy-link", label: "Copy thread link", icon: ICONS.link });
52
+ items.push({ action: "copy-id", label: "Copy thread ID", icon: ICONS.copy });
53
+ items.push(
54
+ inputs.unread
55
+ ? { action: "mark-read", label: "Mark read", icon: ICONS.markRead }
56
+ : { action: "mark-unread", label: "Mark unread", icon: ICONS.markUnread },
57
+ );
58
+ items.push(
59
+ pinned
60
+ ? { action: "unpin", label: "Unpin", icon: ICONS.unpin }
61
+ : { action: "pin", label: "Pin", icon: ICONS.pin },
62
+ );
63
+ if (inputs.isRoot && !archived && inputs.hasSections) {
64
+ items.push({ action: "move-to-section", label: "Move to section", icon: ICONS.moveToSection });
65
+ }
66
+ if (!archived) items.push({ action: "move", label: "Move…", icon: ICONS.move });
67
+ items.push({ action: "rename", label: "Rename", icon: ICONS.rename });
68
+ items.push(
69
+ archived
70
+ ? { action: "unarchive", label: "Unarchive", icon: ICONS.unarchive, separated: true }
71
+ : { action: "archive", label: "Archive", icon: ICONS.archive, separated: true },
72
+ );
73
+ items.push({ action: "delete", label: "Delete", icon: ICONS.remove, destructive: true });
74
+ return items;
75
+ }
76
+
77
+ /** Mark all read: above this many threads, ask first. */
78
+ export const MARK_ALL_CONFIRM_ABOVE = 20;
@@ -0,0 +1,46 @@
1
+ // "Move…": the keyboard route to what drag and drop does. Pure.
2
+ import type { Forest } from "./families";
3
+ import { isInSubtree } from "./drag";
4
+
5
+ export interface MoveTarget {
6
+ /** null moves the thread to the top level. */
7
+ parentThreadId: string | null;
8
+ label: string;
9
+ /** For the list: the parent's own parent, so nested targets read in context. */
10
+ detail: string | null;
11
+ }
12
+
13
+ /**
14
+ * Where a thread can move: the top level (when it has a parent) and under
15
+ * any other visible, active thread outside its own subtree, best matches
16
+ * of `query` first. The current parent is left out: moving there changes
17
+ * nothing.
18
+ */
19
+ export function moveTargets(threadId: string, forest: Forest, query = "", limit = 50): MoveTarget[] {
20
+ const self = forest.infos.get(threadId);
21
+ if (self === undefined) return [];
22
+ const parentOf = (id: string) => forest.infos.get(id)?.thread.parentThreadId ?? null;
23
+ const needle = query.trim().toLowerCase();
24
+ const targets: MoveTarget[] = [];
25
+ if (self.thread.parentThreadId !== null && (needle === "" || "top level".includes(needle))) {
26
+ targets.push({ parentThreadId: null, label: "Top level", detail: "No parent thread" });
27
+ }
28
+ const candidates = [...forest.infos.values()]
29
+ .filter((info) => {
30
+ const thread = info.thread;
31
+ if (thread.isHidden || thread.isArchived) return false;
32
+ if (thread.id === self.thread.parentThreadId) return false;
33
+ if (isInSubtree(thread.id, threadId, parentOf)) return false;
34
+ return needle === "" || thread.displayTitle.toLowerCase().includes(needle);
35
+ })
36
+ .sort((a, b) => b.thread.latestAttentionAt - a.thread.latestAttentionAt);
37
+ for (const info of candidates.slice(0, limit)) {
38
+ const parent = info.parentId === null ? null : forest.infos.get(info.parentId);
39
+ targets.push({
40
+ parentThreadId: info.thread.id,
41
+ label: info.thread.displayTitle,
42
+ detail: parent ? `in ${parent.thread.displayTitle}` : null,
43
+ });
44
+ }
45
+ return targets;
46
+ }
@@ -0,0 +1,52 @@
1
+ // One-line reasons under blocked and failed rows. Pure:
2
+ // the server stores what it saw; this decides what a row says.
3
+ import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
4
+ import type { Note, ThreadNotes } from "@/shared/contract";
5
+ import { normalizeQueued, normalizeStatus, type NeedsKind } from "./state";
6
+
7
+ export interface RowNote {
8
+ /** "Asks", "Approve", "Plan", "Failed"… */
9
+ prefix: string;
10
+ text: string;
11
+ tone: "attention" | "destructive";
12
+ }
13
+
14
+ const PENDING_PREFIX: Record<NeedsKind, string> = {
15
+ question: "Asks",
16
+ approval: "Approve",
17
+ plan: "Plan",
18
+ input: "Needs",
19
+ };
20
+
21
+ /** The needs-you kind a pending note names, or null when there is none. */
22
+ export function needsKindOf(notes: ThreadNotes | undefined): NeedsKind | null {
23
+ const kind = notes?.pending?.kind;
24
+ return kind === "question" || kind === "approval" || kind === "plan" || kind === "input" ? kind : null;
25
+ }
26
+
27
+ /**
28
+ * The line under a row: why it needs you, or why it failed. Other states
29
+ * have none, so the list stays one line per thread where nothing is wrong.
30
+ */
31
+ export function rowNote(thread: PluginSidebarThread, notes: ThreadNotes | undefined): RowNote | null {
32
+ if (thread.hasPendingInteraction) {
33
+ const kind = needsKindOf(notes);
34
+ const text = notes?.pending?.text.trim();
35
+ if (kind === null || !text) return null;
36
+ return { prefix: PENDING_PREFIX[kind], text, tone: "attention" };
37
+ }
38
+ if (normalizeStatus(thread) === "error") {
39
+ const text = notes?.failed?.text.trim();
40
+ // "Failed: Failed" says nothing the red glyph doesn't.
41
+ return text && text.toLowerCase() !== "failed" ? { prefix: "Failed", text, tone: "destructive" } : null;
42
+ }
43
+ if (normalizeQueued(thread) === "failed") {
44
+ return { prefix: "Failed", text: "queued message wasn't sent", tone: "destructive" };
45
+ }
46
+ return null;
47
+ }
48
+
49
+ /** The hover card's last-reply line. */
50
+ export function lastReply(notes: ThreadNotes | undefined): Note | null {
51
+ return notes?.done ?? null;
52
+ }
@@ -0,0 +1,59 @@
1
+ // Two-letter provider marks when a provider has no logo. Pure.
2
+
3
+ export interface ProviderLike {
4
+ id: string;
5
+ displayName?: string | null;
6
+ }
7
+
8
+ function words(text: string): string[] {
9
+ return text
10
+ .split(/[^\p{L}\p{N}]+/u)
11
+ .map((word) => word.trim())
12
+ .filter((word) => word.length > 0);
13
+ }
14
+
15
+ function candidates(provider: ProviderLike): string[] {
16
+ const name = provider.displayName?.trim() || provider.id;
17
+ const parts = words(name);
18
+ const idParts = words(provider.id);
19
+ const first = (parts[0] ?? provider.id)[0] ?? "?";
20
+ const options: string[] = [];
21
+ const push = (second: string | undefined) => {
22
+ if (second === undefined) return;
23
+ const mark = (first + second).toUpperCase();
24
+ if (!options.includes(mark)) options.push(mark);
25
+ };
26
+ // Default: the first two letters of the name.
27
+ push((parts[0] ?? "")[1]);
28
+ // On a clash: the next word's initial, then letters from the id.
29
+ for (const part of parts.slice(1)) push(part[0]);
30
+ for (const part of idParts) for (const letter of part) push(letter);
31
+ for (const letter of (parts[0] ?? "").slice(2)) push(letter);
32
+ if (options.length === 0) options.push(first.toUpperCase());
33
+ return options;
34
+ }
35
+
36
+ /**
37
+ * Assigns each provider a two-letter mark, unique within the roster.
38
+ * Providers keep roster order, so the first of two clashing names keeps the
39
+ * default mark.
40
+ */
41
+ export function assignProviderMarks(roster: readonly ProviderLike[]): Map<string, string> {
42
+ const marks = new Map<string, string>();
43
+ const used = new Set<string>();
44
+ for (const provider of roster) {
45
+ const options = candidates(provider);
46
+ const mark = options.find((option) => !used.has(option)) ?? options[0]!;
47
+ used.add(mark);
48
+ marks.set(provider.id, mark);
49
+ }
50
+ return marks;
51
+ }
52
+
53
+ /** The mark for one provider id, falling back to the id while loading. */
54
+ export function providerMark(
55
+ providerId: string,
56
+ marks: ReadonlyMap<string, string>,
57
+ ): string {
58
+ return marks.get(providerId) ?? candidates({ id: providerId })[0]!;
59
+ }