@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,168 @@
1
+ // The row menu as a right-click context menu or a "…" dropdown, which
2
+ // becomes a bottom drawer on compact viewports.
3
+ import { Fragment } from "react";
4
+ import { experimental_Icon as Icon } from "@get-bb/plugin-sdk/app";
5
+ import type { PluginSidebarSection } from "@get-bb/plugin-sdk/app";
6
+ import {
7
+ ContextMenuContent,
8
+ ContextMenuItem,
9
+ ContextMenuSeparator,
10
+ ContextMenuSub,
11
+ ContextMenuSubContent,
12
+ ContextMenuSubTrigger,
13
+ } from "@/components/ui/context-menu";
14
+ import {
15
+ DropdownMenuContent,
16
+ DropdownMenuItem,
17
+ DropdownMenuSeparator,
18
+ DropdownMenuSub,
19
+ DropdownMenuSubContent,
20
+ DropdownMenuSubTrigger,
21
+ } from "@/components/ui/dropdown-menu";
22
+ import { cn } from "@/lib/utils";
23
+ import { ICONS } from "../icons";
24
+ import type { RowMenuAction, RowMenuItem } from "../model/menu";
25
+
26
+ interface MenuProps {
27
+ items: readonly RowMenuItem[];
28
+ sections: readonly PluginSidebarSection[];
29
+ currentSectionId: string | null;
30
+ onAction(action: RowMenuAction, sectionId?: string | null): void;
31
+ /** Rename keeps focus in its editor instead of returning it to the row. */
32
+ onCloseAutoFocus(event: Event): void;
33
+ }
34
+
35
+ function ItemLabel({ item }: { item: Pick<RowMenuItem, "icon" | "label"> }) {
36
+ return (
37
+ <>
38
+ <Icon name={item.icon} aria-hidden className="size-4" />
39
+ <span>{item.label}</span>
40
+ </>
41
+ );
42
+ }
43
+
44
+ function sectionTargets(sections: readonly PluginSidebarSection[]) {
45
+ return [{ id: null as string | null, name: "Threads" }, ...sections.map((s) => ({ id: s.id as string | null, name: s.name }))];
46
+ }
47
+
48
+ /**
49
+ * What the user has done inside an open context menu. The right-click that
50
+ * opens it is released over whatever item opened under the pointer (the
51
+ * menu opens upward near the bottom of the list), and that release selects
52
+ * the item. A real choice is preceded by a new press or a key in the menu;
53
+ * the row resets this when the menu opens.
54
+ */
55
+ export interface ContextMenuInput {
56
+ pressed: boolean;
57
+ keyed: boolean;
58
+ }
59
+
60
+ export function RowContextMenuContent({
61
+ items,
62
+ sections,
63
+ currentSectionId,
64
+ onAction,
65
+ onCloseAutoFocus,
66
+ input,
67
+ }: MenuProps & {
68
+ input: { readonly current: ContextMenuInput };
69
+ }) {
70
+ const guard = (run: () => void) => (event: Event) => {
71
+ if (!input.current.pressed && !input.current.keyed) {
72
+ event.preventDefault();
73
+ return;
74
+ }
75
+ run();
76
+ };
77
+ return (
78
+ <ContextMenuContent
79
+ className="min-w-48"
80
+ onCloseAutoFocus={onCloseAutoFocus}
81
+ onPointerDown={() => {
82
+ input.current.pressed = true;
83
+ }}
84
+ onKeyDown={() => {
85
+ input.current.keyed = true;
86
+ }}
87
+ >
88
+ {items.map((item) => (
89
+ <Fragment key={item.action}>
90
+ {item.separated ? <ContextMenuSeparator /> : null}
91
+ {item.action === "move-to-section" ? (
92
+ <ContextMenuSub>
93
+ <ContextMenuSubTrigger>
94
+ <ItemLabel item={item} />
95
+ </ContextMenuSubTrigger>
96
+ <ContextMenuSubContent>
97
+ {sectionTargets(sections).map((target) => (
98
+ <ContextMenuItem
99
+ key={target.id ?? "threads"}
100
+ disabled={target.id === currentSectionId}
101
+ onSelect={guard(() => onAction("move-to-section", target.id))}
102
+ >
103
+ {target.id === currentSectionId ? (
104
+ <Icon name={ICONS.check} aria-hidden className="size-4" />
105
+ ) : (
106
+ <span className="size-4" />
107
+ )}
108
+ {target.name}
109
+ </ContextMenuItem>
110
+ ))}
111
+ </ContextMenuSubContent>
112
+ </ContextMenuSub>
113
+ ) : (
114
+ <ContextMenuItem
115
+ className={cn(item.destructive && "text-destructive focus:text-destructive")}
116
+ onSelect={guard(() => onAction(item.action))}
117
+ >
118
+ <ItemLabel item={item} />
119
+ </ContextMenuItem>
120
+ )}
121
+ </Fragment>
122
+ ))}
123
+ </ContextMenuContent>
124
+ );
125
+
126
+ }
127
+
128
+ export function RowDropdownMenuContent({ items, sections, currentSectionId, onAction, onCloseAutoFocus }: MenuProps) {
129
+ return (
130
+ <DropdownMenuContent align="end" className="min-w-48" onCloseAutoFocus={onCloseAutoFocus}>
131
+ {items.map((item) => (
132
+ <Fragment key={item.action}>
133
+ {item.separated ? <DropdownMenuSeparator /> : null}
134
+ {item.action === "move-to-section" ? (
135
+ <DropdownMenuSub>
136
+ <DropdownMenuSubTrigger>
137
+ <ItemLabel item={item} />
138
+ </DropdownMenuSubTrigger>
139
+ <DropdownMenuSubContent>
140
+ {sectionTargets(sections).map((target) => (
141
+ <DropdownMenuItem
142
+ key={target.id ?? "threads"}
143
+ disabled={target.id === currentSectionId}
144
+ onSelect={() => onAction("move-to-section", target.id)}
145
+ >
146
+ {target.id === currentSectionId ? (
147
+ <Icon name={ICONS.check} aria-hidden className="size-4" />
148
+ ) : (
149
+ <span className="size-4" />
150
+ )}
151
+ {target.name}
152
+ </DropdownMenuItem>
153
+ ))}
154
+ </DropdownMenuSubContent>
155
+ </DropdownMenuSub>
156
+ ) : (
157
+ <DropdownMenuItem
158
+ className={cn(item.destructive && "text-destructive focus:text-destructive")}
159
+ onSelect={() => onAction(item.action)}
160
+ >
161
+ <ItemLabel item={item} />
162
+ </DropdownMenuItem>
163
+ )}
164
+ </Fragment>
165
+ ))}
166
+ </DropdownMenuContent>
167
+ );
168
+ }
@@ -0,0 +1,55 @@
1
+ // bb's 14×14 split mini-map: outranks the state glyph, as in bb's list.
2
+ import { cn } from "@/lib/utils";
3
+
4
+ export interface MiniMapPane {
5
+ paneId: string;
6
+ rect: { x: number; y: number; width: number; height: number };
7
+ isMe: boolean;
8
+ isFocused: boolean;
9
+ }
10
+
11
+ export function SplitMiniMap({
12
+ panes,
13
+ label,
14
+ working,
15
+ }: {
16
+ panes: readonly MiniMapPane[];
17
+ label: string;
18
+ working: boolean;
19
+ }) {
20
+ const focused = panes.some((pane) => pane.isMe && pane.isFocused);
21
+ return (
22
+ <svg
23
+ role="img"
24
+ aria-label={label}
25
+ viewBox="0 0 14 14"
26
+ width={14}
27
+ height={14}
28
+ shapeRendering="crispEdges"
29
+ className={cn("pointer-events-none size-3.5 shrink-0", !focused && "opacity-60", working && "animate-shine-icon motion-reduce:animate-none")}
30
+ >
31
+ {panes.map((pane) => {
32
+ // bb's geometry: a 1px inset, 12px of drawable space, and a half-pixel
33
+ // stroke inset for outlined panes.
34
+ const inset = pane.isMe ? 0 : 0.5;
35
+ return (
36
+ <rect
37
+ key={pane.paneId}
38
+ x={1 + pane.rect.x * 12 + inset}
39
+ y={1 + pane.rect.y * 12 + inset}
40
+ width={Math.max(pane.rect.width * 12 - 2 * inset, 0)}
41
+ height={Math.max(pane.rect.height * 12 - 2 * inset, 0)}
42
+ strokeWidth={pane.isMe ? 0 : 1}
43
+ className={
44
+ pane.isMe
45
+ ? pane.isFocused
46
+ ? "fill-primary/70 stroke-none"
47
+ : "fill-muted-foreground/45 stroke-none"
48
+ : "fill-none stroke-muted-foreground/30"
49
+ }
50
+ />
51
+ );
52
+ })}
53
+ </svg>
54
+ );
55
+ }
@@ -0,0 +1,138 @@
1
+ // Hover card and details dialog content.
2
+ import { useContext, useEffect, useState } from "react";
3
+ import { experimental_Icon as Icon, experimental_useSidebarThreadPullRequest as usePullRequest } from "@get-bb/plugin-sdk/app";
4
+ import { Button } from "@/components/ui/button";
5
+ import type { ThreadInfo } from "../model/families";
6
+ import { childSummary, descendantsOf, formatDateTime, sinceLabel } from "../model/details";
7
+ import { lastReply } from "../model/notes";
8
+ import { finishedAtFor, stateSince } from "../model/time";
9
+ import { ICONS } from "../icons";
10
+ import { ListLiveContext, type ModelInfo, type RowController } from "./controller";
11
+ import { GlyphIcon } from "./glyphs";
12
+ import { ProviderBadge } from "./ProviderBadge";
13
+ import { pullRequestLabel, pullRequestTone } from "./PullRequestBadge";
14
+
15
+ function Line({ label, title, children }: { label: string; title?: string; children: React.ReactNode }) {
16
+ return (
17
+ <div className="flex min-w-0 gap-2" title={title}>
18
+ <dt className="w-20 shrink-0 text-muted-foreground">{label}</dt>
19
+ <dd className="min-w-0 flex-1 break-words">{children}</dd>
20
+ </div>
21
+ );
22
+ }
23
+
24
+ function useModel(controller: RowController, info: ThreadInfo): ModelInfo | null | "loading" {
25
+ const [model, setModel] = useState<ModelInfo | null | "loading">("loading");
26
+ const { id, status } = info.thread;
27
+ useEffect(() => {
28
+ let cancelled = false;
29
+ setModel("loading");
30
+ controller.loadModel(id, status).then(
31
+ (result) => !cancelled && setModel(result),
32
+ () => !cancelled && setModel(null),
33
+ );
34
+ return () => {
35
+ cancelled = true;
36
+ };
37
+ }, [controller, id, status]);
38
+ return model;
39
+ }
40
+
41
+ function PullRequestLine({ threadId }: { threadId: string }) {
42
+ const { pullRequest } = usePullRequest(threadId);
43
+ if (pullRequest === null) return null;
44
+ return (
45
+ <Line label="Pull request">
46
+ <span className={pullRequestTone(pullRequest.attention)}>{pullRequestLabel(pullRequest)}</span>
47
+ </Line>
48
+ );
49
+ }
50
+
51
+ export interface DetailsActions {
52
+ open(): void;
53
+ toggleRead(): void;
54
+ }
55
+
56
+ export function ThreadDetails({
57
+ info,
58
+ controller,
59
+ showPullRequest,
60
+ actions,
61
+ }: {
62
+ info: ThreadInfo;
63
+ controller: RowController;
64
+ showPullRequest: boolean;
65
+ /** Present in the details dialog, absent in the hover card. */
66
+ actions?: DetailsActions;
67
+ }) {
68
+ const thread = info.thread;
69
+ const provider = controller.provider(thread.providerId);
70
+ const model = useModel(controller, info);
71
+ const live = useContext(ListLiveContext);
72
+ const since = sinceLabel(info.state.kind, stateSince(info.state.kind, thread.id, live.stamps, live.now));
73
+ const children = childSummary(descendantsOf(live.familyOf(thread.id), thread.id));
74
+ const environment = thread.environment;
75
+ const finished = finishedAtFor(thread, live.stamps.finishedAt);
76
+ const reply = lastReply(live.notes[thread.id]);
77
+ return (
78
+ <div className="flex flex-col gap-2 text-xs">
79
+ <p className="text-sm font-medium leading-snug break-words">{thread.displayTitle}</p>
80
+ <div className="flex items-center gap-1.5">
81
+ <GlyphIcon glyph={info.state.glyph} className="size-3.5" />
82
+ <span>
83
+ {info.state.label}
84
+ {since !== null ? ` · ${since}` : ""}
85
+ {info.state.sendAt !== null ? ` · sends ${formatDateTime(info.state.sendAt)}` : ""}
86
+ </span>
87
+ </div>
88
+ {info.note !== null ? (
89
+ <p className="break-words leading-snug">
90
+ <span className={info.note.tone === "destructive" ? "text-destructive" : "text-attention"}>{info.note.prefix}:</span>{" "}
91
+ {info.note.text}
92
+ </p>
93
+ ) : null}
94
+ <dl className="flex flex-col gap-1">
95
+ <Line label="Harness">
96
+ <span className="inline-flex items-center gap-1.5">
97
+ <ProviderBadge display={provider} mode={controller.harnessIcon === "hidden" ? "muted" : controller.harnessIcon} />
98
+ {provider.name}
99
+ </span>
100
+ </Line>
101
+ <Line label="Model" title="The model and reasoning level the next message will use">
102
+ {model === "loading" ? "…" : model === null ? "Unknown" : `${model.model} · ${model.reasoningLevel}`}
103
+ </Line>
104
+ {environment?.branchName ? (
105
+ <Line label="Branch">
106
+ <span className="inline-flex items-center gap-1">
107
+ <Icon name={environment.isWorktree ? ICONS.worktree : ICONS.branch} aria-hidden className="size-3.5" />
108
+ {environment.branchName}
109
+ {environment.isWorktree ? " (worktree)" : ""}
110
+ </span>
111
+ </Line>
112
+ ) : null}
113
+ {thread.host !== null ? <Line label="Machine">{thread.host.name}</Line> : null}
114
+ {children !== null ? <Line label="Children">{children}</Line> : null}
115
+ {showPullRequest ? <PullRequestLine threadId={thread.id} /> : null}
116
+ {reply !== null && info.note === null ? (
117
+ <Line label="Last reply">
118
+ <span className="line-clamp-2">{reply.text}</span>
119
+ </Line>
120
+ ) : null}
121
+ <Line label="Created">{formatDateTime(thread.createdAt)}</Line>
122
+ {finished !== null ? <Line label="Finished">{formatDateTime(finished)}</Line> : null}
123
+ </dl>
124
+ {actions ? (
125
+ <div className="flex gap-2 pt-1">
126
+ <Button size="sm" onClick={actions.open}>
127
+ <Icon name={ICONS.open} aria-hidden />
128
+ Open
129
+ </Button>
130
+ <Button size="sm" variant="outline" onClick={actions.toggleRead}>
131
+ <Icon name={info.unread ? ICONS.markRead : ICONS.markUnread} aria-hidden />
132
+ {info.unread ? "Mark read" : "Mark unread"}
133
+ </Button>
134
+ </div>
135
+ ) : null}
136
+ </div>
137
+ );
138
+ }