@gperezmz/bb-plugin-thread-glance 0.2.3 → 0.2.4

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.
@@ -3,7 +3,7 @@
3
3
  "sdkVersion": "0.5.9",
4
4
  "artifactFormatVersion": 1,
5
5
  "pluginId": "thread-glance",
6
- "pluginVersion": "0.2.3",
6
+ "pluginVersion": "0.2.4",
7
7
  "builtWith": {
8
8
  "bbVersion": "0.43.4",
9
9
  "pluginSdkVersion": "0.5.9"
@@ -22,7 +22,7 @@ import { ROW_ICON_BUTTON } from "./ThreadRowView";
22
22
  export const OlderRowView = memo(function OlderRowView({ row, controller }: { row: OlderRow; controller: RowController }) {
23
23
  const { label, ariaLabel } = olderRowText(row);
24
24
  // A family's fold sits where its children do, with a dots glyph in the
25
- // status slot; the group's fold keeps the chevron.
25
+ // Status column; the group's fold keeps the chevron.
26
26
  const inFamily = row.scope !== "group";
27
27
  return (
28
28
  <button
@@ -43,8 +43,6 @@ export interface GroupController {
43
43
 
44
44
  export type DropStates = ReadonlyMap<string, "valid" | "blocked" | "unchanged" | "before" | "after">;
45
45
 
46
- /** The sticky toolbar's height; headers stick below it. */
47
- export const TOOLBAR_HEIGHT = 28;
48
46
  const ROW_HEIGHT = { compact: 28, comfortable: 44 };
49
47
 
50
48
  function canRename(group: GroupView): boolean {
@@ -96,9 +94,8 @@ const GroupHeader = memo(function GroupHeader({
96
94
  role={undefined}
97
95
  tabIndex={undefined}
98
96
  data-sidebar="group-label"
99
- style={{ top: inOverflow ? 0 : TOOLBAR_HEIGHT }}
100
97
  className={cn(
101
- "group/header sticky z-20 flex h-7 items-center gap-1 rounded-md bg-sidebar pl-2 pr-1 text-xs text-muted-foreground max-md:pointer-coarse:h-9",
98
+ "group/header sticky top-0 z-20 flex h-7 items-center gap-1 rounded-md bg-sidebar pl-2 pr-1 text-xs text-muted-foreground max-md:pointer-coarse:h-9",
102
99
  dropActive && "bg-sidebar-accent",
103
100
  draggable.isDragging && "opacity-50",
104
101
  !controller.compact && !inOverflow && "select-none",
@@ -431,8 +428,7 @@ export const AttentionSection = memo(function AttentionSection({ view, ...rest }
431
428
  return (
432
429
  <section aria-label="Needs attention" className={cn("relative mb-1 flex w-full min-w-0 flex-col rounded-md pb-0.5", ATTENTION_BAND)}>
433
430
  <h2
434
- style={{ top: TOOLBAR_HEIGHT }}
435
- className="sticky z-20 flex h-7 items-center gap-1 rounded-t-md bg-[var(--tg-surface)] pl-2 pr-1 text-xs font-medium text-muted-foreground max-md:pointer-coarse:h-9"
431
+ className="sticky top-0 z-20 flex h-7 items-center gap-1 rounded-t-md bg-[var(--tg-surface)] pl-2 pr-1 text-xs font-medium text-muted-foreground max-md:pointer-coarse:h-9"
436
432
  >
437
433
  <span className="min-w-0 flex-1 truncate">Needs attention</span>
438
434
  {view.familyCount > 0 ? (
@@ -1,20 +1,21 @@
1
- // The top of the scroll area: the settings button and its popover.
2
- import { experimental_Icon as Icon } from "@get-bb/plugin-sdk/app";
3
- import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
1
+ // Thread Glance's settings, shown by its item in bb's sidebar footer.
2
+ import { experimental_Icon as Icon, type ExperimentalSidebarFooterDisclosureProps } from "@get-bb/plugin-sdk/app";
4
3
  import { cn } from "@/lib/utils";
5
4
  import type { ClientPreferences, Preferences } from "@/shared/preferences";
6
5
  import { childAttentionFor, countsEveryChild, lifecyclesFor, sortArrow, sortFieldPatch, threadsShown } from "../model/settings";
7
6
  import { effectiveSortField } from "../model/sort";
7
+ import { useClientPreferences } from "../data/useClientPreferences";
8
+ import { usePreferences } from "../data/usePreferences";
8
9
  import { ICONS } from "../icons";
9
- import { TOOLBAR_HEIGHT } from "./GroupSection";
10
- import { ROW_ICON_BUTTON } from "./ThreadRowView";
11
10
 
12
11
  // bb's own segmented controls (the Reasoning picker, the diff view toggle)
13
12
  // mark the chosen item with the state-active token and no shadow. The track
14
13
  // is a translucent tint, so the chosen item reads as raised in both themes.
15
- const SEGMENT_TRACK = "flex min-w-0 flex-1 gap-0.5 rounded-md bg-surface-recessed p-0.5";
14
+ // An item never shrinks below its text: where a line cannot hold its label
15
+ // and its control, the control wraps onto a line of its own.
16
+ const SEGMENT_TRACK = "flex flex-1 gap-0.5 rounded-md bg-surface-recessed p-0.5";
16
17
  const SEGMENT_ITEM =
17
- "flex min-w-0 flex-1 items-center justify-center whitespace-nowrap rounded-sm px-1 py-0.5 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring";
18
+ "flex flex-1 items-center justify-center whitespace-nowrap rounded-sm px-1 py-0.5 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring";
18
19
 
19
20
  function segmentClass(selected: boolean): string {
20
21
  return cn(
@@ -25,12 +26,12 @@ function segmentClass(selected: boolean): string {
25
26
  );
26
27
  }
27
28
 
28
- /** A label, then its control, on one line. */
29
+ /** A label, then its control: on one line where the panel is wide enough, else the control below. */
29
30
  function Line({ label, children }: { label: string; children: React.ReactNode }) {
30
31
  return (
31
- <div className="flex items-center gap-2 px-2 py-1">
32
- <span className="w-[5.5rem] shrink-0 text-sm">{label}</span>
33
- {children}
32
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-2 py-1">
33
+ <span className="min-w-[5.5rem] shrink-0 whitespace-nowrap text-sm">{label}</span>
34
+ <div className="flex flex-1 gap-2">{children}</div>
34
35
  </div>
35
36
  );
36
37
  }
@@ -104,7 +105,7 @@ function Heading({ children }: { children: React.ReactNode }) {
104
105
  return <h3 className="px-2 pb-0.5 pt-3 text-xs font-medium text-muted-foreground first:pt-0">{children}</h3>;
105
106
  }
106
107
 
107
- export function SettingsPanel({
108
+ function SettingsPanel({
108
109
  prefs,
109
110
  client,
110
111
  onPrefs,
@@ -208,31 +209,13 @@ export function SettingsPanel({
208
209
  );
209
210
  }
210
211
 
211
- /** The slim row above the list: ⚙ alone at its right. */
212
- export function Toolbar({
213
- prefs,
214
- client,
215
- onPrefs,
216
- onClient,
217
- }: {
218
- prefs: Preferences;
219
- client: ClientPreferences;
220
- onPrefs(patch: Partial<Preferences>): void;
221
- onClient(patch: Partial<ClientPreferences>): void;
222
- }) {
212
+ /** The footer item's panel, reading and writing the preferences the list reads. */
213
+ export function SettingsDisclosure(_props: ExperimentalSidebarFooterDisclosureProps) {
214
+ const { prefs, update } = usePreferences();
215
+ const [client, updateClient] = useClientPreferences();
223
216
  return (
224
- // Sticky with the group headers, so the settings stay in reach.
225
- <div style={{ height: TOOLBAR_HEIGHT }} className="sticky top-0 z-30 flex items-center justify-end bg-sidebar">
226
- <Popover>
227
- <PopoverTrigger asChild>
228
- <button type="button" aria-label="Thread Glance settings" title="List settings" className={ROW_ICON_BUTTON}>
229
- <Icon name={ICONS.settings} aria-hidden className="size-4" />
230
- </button>
231
- </PopoverTrigger>
232
- <PopoverContent align="end" className="w-80 p-2">
233
- <SettingsPanel prefs={prefs} client={client} onPrefs={onPrefs} onClient={onClient} />
234
- </PopoverContent>
235
- </Popover>
217
+ <div className="p-1">
218
+ <SettingsPanel prefs={prefs} client={client} onPrefs={update} onClient={updateClient} />
236
219
  </div>
237
220
  );
238
221
  }
@@ -54,7 +54,6 @@ import { cancelPendingCards } from "./row-card";
54
54
  import { GroupSection, AttentionSection, type DropStates, type GroupController } from "./GroupSection";
55
55
  import type { ProviderDisplay } from "./ProviderBadge";
56
56
  import { ThreadDetails } from "./ThreadDetails";
57
- import { Toolbar } from "./Toolbar";
58
57
 
59
58
  const PLUGIN_ID = "thread-glance";
60
59
 
@@ -108,7 +107,7 @@ function ThreadListBody({
108
107
  onRetry,
109
108
  }: PluginThreadListProps & { attempt: number; onRetry(): void }) {
110
109
  const { prefs, hydrated, update } = usePreferences();
111
- const [client, updateClient] = useClientPreferences();
110
+ const [client] = useClientPreferences();
112
111
  const sidebar = useSidebarThreads({ experimental_lifecycles: prefs.threadLifecycles });
113
112
  const actions = useThreadActions();
114
113
  const sdk = useSdk();
@@ -679,7 +678,6 @@ function ThreadListBody({
679
678
  return (
680
679
  <ListLiveContext.Provider value={live}>
681
680
  <div className="flex w-full min-w-0 flex-col px-1.5 pb-2">
682
- <Toolbar prefs={prefs} client={client} onPrefs={update} onClient={updateClient} />
683
681
  {threads.length === 0 ? (
684
682
  // bb's own pinned New thread button covers the empty list.
685
683
  <p className="px-3 py-4 text-sm text-muted-foreground">No threads yet.</p>
@@ -47,6 +47,18 @@ export function UnreadDot({ label, className }: { label?: string; className?: st
47
47
  );
48
48
  }
49
49
 
50
+ // The Idle state's ring: uncoloured, smaller than the column and thinned, so
51
+ // it reads as status yet stays fainter than the unread dot and the draft
52
+ // pencil. Screen readers skip it, so an Idle row announces what it did before.
53
+ export function IdleRing({ className }: { className?: string }) {
54
+ return (
55
+ <span
56
+ aria-hidden
57
+ className={cn("inline-block size-2.5 shrink-0 rounded-full border-[1.5px] border-current opacity-50", className)}
58
+ />
59
+ );
60
+ }
61
+
50
62
  export function GlyphIcon({
51
63
  glyph,
52
64
  label,
@@ -56,7 +68,7 @@ export function GlyphIcon({
56
68
  label?: string;
57
69
  className?: string;
58
70
  }) {
59
- if (glyph.icon === null) return null;
71
+ if (glyph.icon === "ring") return <IdleRing className={TONE_CLASS[glyph.tone]} />;
60
72
  if (glyph.icon === "dot") return <UnreadDot label={label} />;
61
73
  return (
62
74
  <Icon
@@ -0,0 +1,34 @@
1
+ // The list and the settings panel mount apart, each with its own copy of the
2
+ // preferences. A change made in one reaches the other here, in the same
3
+ // window, at once; the server and localStorage stay what other windows read.
4
+ import { useCallback, useEffect } from "react";
5
+
6
+ type Apply<Patch> = (patch: Patch) => void;
7
+
8
+ export function sameWindow<Patch>(): Set<Apply<Patch>> {
9
+ return new Set();
10
+ }
11
+
12
+ /**
13
+ * Joins this copy to `copies` for as long as it is mounted.
14
+ *
15
+ * Returns the call that hands a patch this copy made to every other copy.
16
+ */
17
+ export function useSameWindow<State extends object>(
18
+ copies: Set<Apply<Partial<State>>>,
19
+ setState: (update: (current: State) => State) => void,
20
+ ): Apply<Partial<State>> {
21
+ const apply = useCallback((patch: Partial<State>) => setState((current) => ({ ...current, ...patch })), [setState]);
22
+ useEffect(() => {
23
+ copies.add(apply);
24
+ return () => {
25
+ copies.delete(apply);
26
+ };
27
+ }, [copies, apply]);
28
+ return useCallback(
29
+ (patch: Partial<State>) => {
30
+ for (const other of copies) if (other !== apply) other(patch);
31
+ },
32
+ [copies, apply],
33
+ );
34
+ }
@@ -5,16 +5,24 @@ import {
5
5
  parseClientPreferences,
6
6
  type ClientPreferences,
7
7
  } from "@/shared/preferences";
8
+ import { sameWindow, useSameWindow } from "./same-window";
8
9
  import { readJson, writeJson } from "./storage";
9
10
 
11
+ const copies = sameWindow<Partial<ClientPreferences>>();
12
+
10
13
  export function useClientPreferences(): [ClientPreferences, (patch: Partial<ClientPreferences>) => void] {
11
14
  const [value, setValue] = useState(() => parseClientPreferences(readJson(CLIENT_PREFERENCES_STORAGE_KEY)));
12
- const update = useCallback((patch: Partial<ClientPreferences>) => {
13
- setValue((current) => {
14
- const next = { ...current, ...patch };
15
- writeJson(CLIENT_PREFERENCES_STORAGE_KEY, next);
16
- return next;
17
- });
18
- }, []);
15
+ const tellOthers = useSameWindow(copies, setValue);
16
+ const update = useCallback(
17
+ (patch: Partial<ClientPreferences>) => {
18
+ setValue((current) => {
19
+ const next = { ...current, ...patch };
20
+ writeJson(CLIENT_PREFERENCES_STORAGE_KEY, next);
21
+ return next;
22
+ });
23
+ tellOthers(patch);
24
+ },
25
+ [tellOthers],
26
+ );
19
27
  return [value, update];
20
28
  }
@@ -14,10 +14,16 @@ import {
14
14
  type PreferenceKey,
15
15
  type Preferences,
16
16
  } from "@/shared/preferences";
17
+ import { sameWindow, useSameWindow } from "./same-window";
17
18
  import { readJson, writeJson } from "./storage";
18
19
 
19
20
  const WRITE_DEBOUNCE_MS = 150;
20
21
 
22
+ const copies = sameWindow<Partial<Preferences>>();
23
+ // Writes not yet sent, whichever copy made them: a reload or an echo in any
24
+ // copy must not put back the value one of them replaced.
25
+ const pending = new Map<PreferenceKey, unknown>();
26
+
21
27
  export interface PreferencesState {
22
28
  prefs: Preferences;
23
29
  /** The server's values have arrived (or failed; the mirror stands in). */
@@ -31,7 +37,6 @@ export function usePreferences(): PreferencesState {
31
37
  coercePreferences(readJson(PREFERENCES_MIRROR_STORAGE_KEY)),
32
38
  );
33
39
  const [hydrated, setHydrated] = useState(false);
34
- const pending = useRef(new Map<PreferenceKey, unknown>());
35
40
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
36
41
  const connection = useRealtimeConnectionState();
37
42
  const wasConnected = useRef(false);
@@ -41,7 +46,7 @@ export function usePreferences(): PreferencesState {
41
46
  const { preferences } = await rpc.call("listPreferences", null);
42
47
  const next = coercePreferences(preferences);
43
48
  // Keys with a write in flight keep the local value.
44
- for (const [key, value] of pending.current) (next as Record<string, unknown>)[key] = value;
49
+ for (const [key, value] of pending) (next as Record<string, unknown>)[key] = value;
45
50
  setPrefs(next);
46
51
  writeJson(PREFERENCES_MIRROR_STORAGE_KEY, next);
47
52
  } finally {
@@ -73,7 +78,7 @@ export function usePreferences(): PreferencesState {
73
78
 
74
79
  useRealtime(CHANNELS.preferences, (payload) => {
75
80
  const signal = payload as PreferenceSignal;
76
- if (!isPreferenceKey(signal?.key) || pending.current.has(signal.key)) return;
81
+ if (!isPreferenceKey(signal?.key) || pending.has(signal.key)) return;
77
82
  const parsed = parsePreference(signal.key, signal.value);
78
83
  if (!parsed.success) return;
79
84
  setPrefs((current) => {
@@ -84,8 +89,8 @@ export function usePreferences(): PreferencesState {
84
89
  });
85
90
 
86
91
  const flush = useCallback(() => {
87
- const writes = [...pending.current];
88
- pending.current.clear();
92
+ const writes = [...pending];
93
+ pending.clear();
89
94
  for (const [key, value] of writes) {
90
95
  rpc.call("setPreference", { key, value }).catch((error: unknown) => {
91
96
  toast.error(`Couldn't save the ${key} setting`, {
@@ -103,6 +108,9 @@ export function usePreferences(): PreferencesState {
103
108
  [flush],
104
109
  );
105
110
 
111
+ // Another copy's change arrives already mirrored and on its way to the server.
112
+ const tellOthers = useSameWindow(copies, setPrefs);
113
+
106
114
  const update = useCallback(
107
115
  (patch: Partial<Preferences>) => {
108
116
  setPrefs((current) => {
@@ -110,8 +118,9 @@ export function usePreferences(): PreferencesState {
110
118
  writeJson(PREFERENCES_MIRROR_STORAGE_KEY, next);
111
119
  return next;
112
120
  });
121
+ tellOthers(patch);
113
122
  for (const [key, value] of Object.entries(patch)) {
114
- if (isPreferenceKey(key)) pending.current.set(key, value);
123
+ if (isPreferenceKey(key)) pending.set(key, value);
115
124
  }
116
125
  if (timer.current !== null) clearTimeout(timer.current);
117
126
  timer.current = setTimeout(() => {
@@ -119,7 +128,7 @@ export function usePreferences(): PreferencesState {
119
128
  flush();
120
129
  }, WRITE_DEBOUNCE_MS);
121
130
  },
122
- [flush],
131
+ [flush, tellOthers],
123
132
  );
124
133
 
125
134
  return { prefs, hydrated, update };
@@ -24,7 +24,6 @@ export const ICONS = {
24
24
  moveUp: "ChevronUp",
25
25
  newThread: "Plus",
26
26
  newSection: "SectionAdd",
27
- settings: "Settings",
28
27
  customize: "SlidersHorizontal",
29
28
  check: "Check",
30
29
  pullRequest: "GitPullRequest",
@@ -1,2 +1,3 @@
1
1
  // The sidebar thread list feature: what the app entry may use.
2
2
  export { ThreadList } from "./components/ThreadList";
3
+ export { SettingsDisclosure } from "./components/SettingsPanel";
@@ -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
- /** Left padding of a root row, as in bb's list. */
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 status slot starts this far right of its parent's, so the
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;
@@ -1,4 +1,4 @@
1
- // What the settings popover's controls show and write. Pure.
1
+ // What the settings panel's controls show and write. Pure.
2
2
  import type { ChildAttention, Lifecycle, Preferences, SortField } from "@/shared/preferences";
3
3
  import { effectiveDirection, effectiveSortField, naturalDirection } from "./sort";
4
4
 
@@ -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
- /** How a glyph is drawn. `dot` is the unread dot; everything else a host icon. */
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" | null;
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(null, "none"), ...none };
237
+ return { kind: "idle", label: "Idle", glyph: glyph("ring", "muted"), ...none };
235
238
  }
236
239
 
237
240
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gperezmz/bb-plugin-thread-glance",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bb": ">=0.43",