@linxin666/dsh-usage 0.3.24 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,87 +0,0 @@
1
- /**
2
- * One trailing action button seated at the entry row's right edge (e.g. a
3
- * refresh command or a collapse chevron). Rows with actions switch from the
4
- * classic single-button structure to a container with a main button plus the
5
- * action buttons (nested interactive elements are invalid HTML); rows without
6
- * actions keep the classic structure untouched.
7
- */
8
- export interface SidebarEntryAction {
9
- /** Stable id stamped as data-dsh-entry-action="<id>" (CSS hooks and tests). */
10
- id: string;
11
- /** Inline icon markup while the row is active (always, when inactiveIcon is unset). */
12
- icon: string;
13
- /**
14
- * Optional icon while the row is inactive. Setting it makes the action a
15
- * state mirror (a collapse chevron): the core swaps the icon and stamps
16
- * aria-expanded on every active-state change. Requires the active bridge.
17
- */
18
- inactiveIcon?: string;
19
- /** Localized accessible label (aria-label + title); receives the active state. */
20
- label(active: boolean): string;
21
- /**
22
- * Click handler; receives the button so the caller can drive transient
23
- * affordances (a refresh cooldown) without a re-render.
24
- */
25
- onClick(button: HTMLButtonElement): void;
26
- }
27
- /** Per-package configuration for one sidebar entry row. */
28
- export interface SidebarEntryOptions {
29
- /** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
30
- rowAttribute: string;
31
- /** CSS selector matching the injected row, e.g. '[data-dsh-ssh-entry]'. */
32
- rowSelector: string;
33
- /**
34
- * L2 semantic-attribute plugin id (issue #506, enum table:
35
- * skins/skin-center/contracts/semantic-attrs-v1.md). When set, the row also
36
- * outputs data-dsh-plugin="<id>" and data-dsh-part="sidebar-entry"; unset
37
- * leaves the row without semantic attributes.
38
- */
39
- plugin?: string;
40
- /** Inline icon markup (matches the shell's 16px nav-icon look). */
41
- icon: string;
42
- /** CSS module class names for the row and its two spans (entry / entryIcon / entryLabel). */
43
- css: Record<string, string>;
44
- /** Localized row label (aria-label + visible text). */
45
- label(): string;
46
- /** Optional localized tooltip (title attribute). */
47
- tooltip?(): string;
48
- /**
49
- * Optional locale-change subscription: re-applies label / aria-label /
50
- * tooltip whenever the active locale changes. Plain-DOM rows would
51
- * otherwise keep the label captured at mount; pass the SDK locale runtime
52
- * subscription (ctx.locale.subscribe) so the row follows the language
53
- * switch without a reload.
54
- */
55
- refresh?: {
56
- subscribe(listener: () => void): () => void;
57
- };
58
- /** Click action (open/toggle the owning panel). */
59
- onToggle(): void;
60
- /**
61
- * Optional trailing action buttons at the row's right edge. Requires the
62
- * package CSS to seat entryMain / entryAction; empty keeps the classic
63
- * single-button row.
64
- */
65
- actions?: readonly SidebarEntryAction[];
66
- /** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
67
- position: 'before' | 'after';
68
- /**
69
- * Selectors of the sibling plugin entry rows this package orders against
70
- * (its own row included — the placement guard excludes a row that is
71
- * already inside the root). Each package passes the same list it used
72
- * before the consolidation so the rendered order stays stable.
73
- */
74
- familySelectors: readonly string[];
75
- /** Optional active-state bridge; highlights the row while the panel is open. */
76
- active?: {
77
- subscribe(listener: () => void): () => void;
78
- isOpen(): boolean;
79
- };
80
- }
81
- /**
82
- * Mount the sidebar entry, waiting for the shell to render and self-healing
83
- * on later React re-renders.
84
- * @param options - the row's attribute/icon/copy/action/ordering configuration.
85
- * @returns disposer removing the entry and its observers.
86
- */
87
- export declare function mountSidebarEntry(options: SidebarEntryOptions): () => void;
@@ -1,35 +0,0 @@
1
- /**
2
- * Sidebar entry injection for the usage panel (issue #1592) — wiring over the
3
- * shared core. Unlike the panel-takeover family (task board / SSH / skill
4
- * center) this row does not participate in the family block, so it is placed
5
- * directly under the New Session row and ordered after those entries. The row
6
- * seats the panel's controls as trailing actions: a refresh command and a
7
- * collapse chevron mirroring the open state, so the panel below renders only
8
- * its content.
9
- * @module @linxin666/dsh-usage/client/sidebar-entry
10
- */
11
- /** Stable data attribute identifying the injected entry row. */
12
- export declare const ENTRY_SELECTOR = "[data-dsh-usage-entry]";
13
- /** Locale-change subscription the shared core asks for (ctx.locale.subscribe shape). */
14
- export interface LocaleRefreshSource {
15
- subscribe(listener: () => void): () => void;
16
- }
17
- /** Panel-facing callbacks and state the entry row's controls drive. */
18
- export interface UsageSidebarEntryHandlers {
19
- /** Toggles the panel (the row's main area and the collapse chevron). */
20
- onToggle(): void;
21
- /** Forces one host probe cycle (the refresh action). */
22
- onRefresh(): void;
23
- /** Reads the panel's expanded state (row highlight and chevron direction). */
24
- isOpen(): boolean;
25
- /** Subscribes to expanded-state changes (the chevron mirrors them live). */
26
- subscribeOpen(listener: () => void): () => void;
27
- }
28
- /**
29
- * Mount the usage sidebar entry.
30
- * @param handlers - the panel's toggle/refresh callbacks and open-state face.
31
- * @param locale - locale-change source; when given, re-applies the label on a
32
- * language switch (the plain-DOM row otherwise keeps the mount-time copy).
33
- * @returns disposer removing the entry and its observers.
34
- */
35
- export declare function mountSidebarEntry(handlers: UsageSidebarEntryHandlers, locale?: LocaleRefreshSource): () => void;
@@ -1,40 +0,0 @@
1
- /**
2
- * Sidebar usage panel mounting (issue #1592).
3
- *
4
- * The panel is a plain DOM row inserted into the sidebar right below the
5
- * usage entry row, carrying its own React root. Placement self-heals with the
6
- * same page-wide body-mutation hub the entry row uses; the entry row stays the
7
- * anchor, so the sidebar order never depends on this module's timing.
8
- *
9
- * The mount owns the panel's single open state: initialized from localStorage,
10
- * flipped by the entry row's toggle controls, and mirrored to the React body
11
- * through the subscribe/isOpen face (the same face the entry row's chevron
12
- * and highlight read).
13
- * @module @linxin666/dsh-usage/client/sidebar-panel-mount
14
- */
15
- import type { UsageStoreInstance } from './usage-store.ts';
16
- /** localStorage key holding the collapsed flag ('1' = collapsed). */
17
- export declare const COLLAPSED_STORAGE_KEY = "dsh-usage.sidebar.collapsed";
18
- /** Panel inputs the mount forwards into the React tree. */
19
- export interface UsagePanelMountProps {
20
- store: UsageStoreInstance;
21
- /** Fetch one overview now. */
22
- poll: () => void;
23
- }
24
- /** Mounted panel controller: the entry row drives the toggle and mirrors the state. */
25
- export interface UsagePanelMount {
26
- /** Whether the panel body is currently expanded. */
27
- isOpen(): boolean;
28
- /** Subscribe to open/collapse changes (the entry row's chevron and highlight). */
29
- subscribe(listener: () => void): () => void;
30
- /** Flip the open state, persist it, and notify subscribers. */
31
- toggle(): void;
32
- /** Unmount the panel and remove its container. */
33
- dispose(): void;
34
- }
35
- /**
36
- * Mount the sidebar usage panel below its entry row.
37
- * @param props - the store and poll callback of the apply body.
38
- * @returns the panel controller.
39
- */
40
- export declare function mountUsagePanel(props: UsagePanelMountProps): UsagePanelMount;
@@ -1,196 +0,0 @@
1
- /**
2
- * Sidebar usage panel (issue #1592): the usage body seated directly under the
3
- * sidebar entry row. The row carries the panel's controls (refresh, collapse
4
- * chevron), so this component renders only the content: expanded, plan
5
- * providers show their quota windows and balance-only providers their
6
- * remaining amount; collapsed, a one-line strip shows the current session
7
- * provider's today usage (tokens, calls, and cost when priced) from the same
8
- * /api/dsh-usage/overview document the settings section renders. The open
9
- * state lives in the mount (persisted to localStorage) and arrives through
10
- * the open face; polling runs at 10s expanded, 30s collapsed, and pauses
11
- * while the page is hidden.
12
- * @module @linxin666/dsh-usage/client/UsageSidebarPanel
13
- */
14
-
15
- import { useEffect, useSyncExternalStore, type ReactNode } from 'react'
16
- import { t } from './locales.ts'
17
- import styles from './usage.module.css'
18
- import { formatTokens } from './UsageSectionCard.tsx'
19
- import type { UsageStoreInstance } from './usage-store.ts'
20
- import { adapterFor } from '../core/adapters.ts'
21
- import { totalTokens } from '../core/ledger.ts'
22
- import type { ProviderSnapshotView, UsageOverviewView, UsageTokenTotals } from '../core/types.ts'
23
- import { addTotals, emptyTotals } from '../core/types.ts'
24
-
25
- /** Expanded-state face the owning mount supplies (subscribe + current value). */
26
- export interface OpenStateSource {
27
- subscribe(listener: () => void): () => void
28
- isOpen(): boolean
29
- }
30
-
31
- /** Props of the sidebar panel (the apply body supplies the store and poller). */
32
- export interface UsageSidebarPanelProps {
33
- store: UsageStoreInstance
34
- /** Fetch one overview now. */
35
- poll: () => void
36
- /** Expanded state owned by the mount; false renders nothing and pauses polling. */
37
- open: OpenStateSource
38
- }
39
-
40
- /** Poll cadence while the panel is expanded and the page visible. */
41
- const PANEL_POLL_MS = 10_000
42
-
43
- /**
44
- * Poll cadence of the collapsed strip: the current session's usage should
45
- * stay visibly live, but a collapsed surface pays a relaxed cadence instead
46
- * of the expanded one.
47
- */
48
- const COLLAPSED_POLL_MS = 30_000
49
-
50
- /** Bar tone, matching the settings section's thresholds. */
51
- function tone(percent: number): string {
52
- if (percent >= 90) return styles.barLow
53
- if (percent >= 70) return styles.barWarn
54
- return styles.sidebarBarFill
55
- }
56
-
57
- /**
58
- * The collapsed-strip facts for the current provider: display name plus
59
- * today's adapter-family totals (tokens, calls, cost). The host serves them
60
- * strip-ready inside `current`; an older host document is derived against
61
- * here. Undefined on a day without usage — a strip about nothing is noise.
62
- */
63
- function collapsedSummary(snapshot: UsageOverviewView): { name: string; totals: UsageTokenTotals } | undefined {
64
- const provider = snapshot.current.provider
65
- if (provider === undefined) return undefined
66
- const name = snapshot.current.displayName
67
- ?? snapshot.providers.find((entry) => entry.provider === provider)?.displayName
68
- ?? provider
69
- if (snapshot.current.today !== undefined) {
70
- const totals = snapshot.current.today
71
- return totals.calls > 0 && totalTokens(totals) > 0 ? { name, totals } : undefined
72
- }
73
- const family = adapterFor(provider)
74
- let merged = emptyTotals()
75
- for (const row of snapshot.usage.today.providers) {
76
- const same = family === undefined ? row.provider === provider : adapterFor(row.provider) === family
77
- if (same) merged = addTotals(merged, row.totals)
78
- }
79
- return merged.calls > 0 && totalTokens(merged) > 0 ? { name, totals: merged } : undefined
80
- }
81
-
82
- /** A provider is worth listing when it is configured and carries a fact. */
83
- function listable(provider: ProviderSnapshotView): boolean {
84
- return provider.credential !== 'none' && (provider.plan !== undefined || provider.balance !== undefined)
85
- }
86
-
87
- /** One provider row: plan windows when present, otherwise the balance line. */
88
- function ProviderBlock(props: { provider: ProviderSnapshotView }): ReactNode {
89
- const { provider } = props
90
- const windows = provider.plan?.windows ?? []
91
- if (windows.length > 0) {
92
- return (
93
- <div className={styles.sidebarProvider} data-dsh-part="sidebar-provider">
94
- <div className={styles.sidebarProviderHead}>
95
- <span className={styles.sidebarProviderName}>{provider.displayName}</span>
96
- </div>
97
- {windows.map((window) => (
98
- <div key={window.key} className={styles.sidebarWindowRow} data-dsh-part="sidebar-window">
99
- <span>{window.name ?? t(`usage.plan.windows.${window.key}`)}</span>
100
- {window.percent !== undefined && (
101
- <>
102
- <span className={styles.sidebarBar}>
103
- <span className={tone(window.percent)} style={{ width: `${Math.min(100, Math.max(0, window.percent))}%` }} />
104
- </span>
105
- <span>{window.percent >= 10 ? Math.round(window.percent) : window.percent.toFixed(1)}%</span>
106
- </>
107
- )}
108
- </div>
109
- ))}
110
- </div>
111
- )
112
- }
113
- if (provider.balance !== undefined) {
114
- return (
115
- <div className={styles.sidebarProvider} data-dsh-part="sidebar-provider">
116
- <div className={styles.sidebarProviderHead}>
117
- <span className={styles.sidebarProviderName}>{provider.displayName}</span>
118
- <span className={styles.sidebarBalance}>
119
- {t('usage.sidebar.balanceLeft', { balance: `${provider.balance.currency} ${provider.balance.totalBalance}` })}
120
- </span>
121
- </div>
122
- </div>
123
- )
124
- }
125
- return null
126
- }
127
-
128
- /**
129
- * Render the sidebar usage panel body.
130
- * @param props - store, the poll callback, and the mount's open-state face.
131
- * @returns the panel content, or null while collapsed.
132
- */
133
- export function UsageSidebarPanel(props: UsageSidebarPanelProps): ReactNode {
134
- const { store, poll, open: openState } = props
135
- const ui = useSyncExternalStore(store.subscribe, store.getSnapshot)
136
- const open = useSyncExternalStore(openState.subscribe, openState.isOpen)
137
-
138
- // The strip stays live at a relaxed cadence while collapsed; the
139
- // expanded panel keeps the section's own 10s cadence. A hidden tab always
140
- // pauses, matching the settings section's gating.
141
- useEffect(() => {
142
- poll()
143
- const cadence = open ? PANEL_POLL_MS : COLLAPSED_POLL_MS
144
- let timer: number | undefined
145
- const start = (): void => {
146
- if (timer === undefined && document.visibilityState === 'visible') timer = window.setInterval(poll, cadence)
147
- }
148
- const onVisibility = (): void => {
149
- if (document.visibilityState === 'visible') {
150
- poll()
151
- start()
152
- } else if (timer !== undefined) {
153
- window.clearInterval(timer)
154
- timer = undefined
155
- }
156
- }
157
- start()
158
- document.addEventListener('visibilitychange', onVisibility)
159
- return () => {
160
- if (timer !== undefined) window.clearInterval(timer)
161
- document.removeEventListener('visibilitychange', onVisibility)
162
- }
163
- }, [poll, open])
164
-
165
- const snapshot: UsageOverviewView | null = ui.snapshot
166
-
167
- if (!open) {
168
- const summary = snapshot === null ? undefined : collapsedSummary(snapshot)
169
- if (summary === undefined) return null
170
- return (
171
- <div className={styles.sidebarSummary} data-dsh-plugin="usage" data-dsh-part="sidebar-summary">
172
- <span className={styles.sidebarSummaryName}>{summary.name}</span>
173
- <span className={styles.sidebarSummaryFacts}>
174
- {t('usage.sidebar.todayUsage', { tokens: formatTokens(totalTokens(summary.totals)) })}
175
- {' · '}
176
- {t('usage.calls', { n: summary.totals.calls })}
177
- {summary.totals.cost > 0 ? ` · ¥${summary.totals.cost.toFixed(2)}` : ''}
178
- </span>
179
- </div>
180
- )
181
- }
182
-
183
- const providers = snapshot === null ? [] : snapshot.providers.filter(listable)
184
-
185
- return (
186
- <div className={styles.sidebarPanel} data-dsh-plugin="usage" data-dsh-part="sidebar-panel">
187
- {ui.status === 'error'
188
- ? <span className={styles.sidebarMuted}>{t('usage.sidebar.error', { error: ui.error ?? '' })}</span>
189
- : snapshot === null
190
- ? <span className={styles.sidebarMuted}>{t('usage.sidebar.loading')}</span>
191
- : providers.length === 0
192
- ? <span className={styles.sidebarMuted}>{t('usage.sidebar.empty')}</span>
193
- : providers.map((provider) => <ProviderBlock key={provider.provider} provider={provider} />)}
194
- </div>
195
- )
196
- }
@@ -1,321 +0,0 @@
1
- // Generated by scripts/sync-shared.mjs from shared/client/sidebar-entry-core.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
- /**
3
- * Shared sidebar entry injection core.
4
- *
5
- * dsh's sidebar shell exposes no slot an external plugin can register into,
6
- * so the entry row is injected between the shell's New Session button and the
7
- * workspace browser. The injection self-heals: a MutationObserver watches the
8
- * sidebar root and re-inserts the row whenever a React re-render displaces it
9
- * (re-insertion happens in the same frame, before paint, so no flicker).
10
- *
11
- * The row is plain DOM (no React tree) so it can never disturb the shell's
12
- * reconciliation; the view it toggles is a separate root owned by the caller.
13
- *
14
- * Packages receive this file as a generated copy via scripts/sync-shared.mjs;
15
- * edit the shared source and re-run the sync instead of editing a copy.
16
- */
17
- import { subscribeBodyInvalidations } from './body-mutations.ts'
18
-
19
- /**
20
- * One trailing action button seated at the entry row's right edge (e.g. a
21
- * refresh command or a collapse chevron). Rows with actions switch from the
22
- * classic single-button structure to a container with a main button plus the
23
- * action buttons (nested interactive elements are invalid HTML); rows without
24
- * actions keep the classic structure untouched.
25
- */
26
- export interface SidebarEntryAction {
27
- /** Stable id stamped as data-dsh-entry-action="<id>" (CSS hooks and tests). */
28
- id: string
29
- /** Inline icon markup while the row is active (always, when inactiveIcon is unset). */
30
- icon: string
31
- /**
32
- * Optional icon while the row is inactive. Setting it makes the action a
33
- * state mirror (a collapse chevron): the core swaps the icon and stamps
34
- * aria-expanded on every active-state change. Requires the active bridge.
35
- */
36
- inactiveIcon?: string
37
- /** Localized accessible label (aria-label + title); receives the active state. */
38
- label(active: boolean): string
39
- /**
40
- * Click handler; receives the button so the caller can drive transient
41
- * affordances (a refresh cooldown) without a re-render.
42
- */
43
- onClick(button: HTMLButtonElement): void
44
- }
45
-
46
- /** Per-package configuration for one sidebar entry row. */
47
- export interface SidebarEntryOptions {
48
- /** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
49
- rowAttribute: string
50
- /** CSS selector matching the injected row, e.g. '[data-dsh-ssh-entry]'. */
51
- rowSelector: string
52
- /**
53
- * L2 semantic-attribute plugin id (issue #506, enum table:
54
- * skins/skin-center/contracts/semantic-attrs-v1.md). When set, the row also
55
- * outputs data-dsh-plugin="<id>" and data-dsh-part="sidebar-entry"; unset
56
- * leaves the row without semantic attributes.
57
- */
58
- plugin?: string
59
- /** Inline icon markup (matches the shell's 16px nav-icon look). */
60
- icon: string
61
- /** CSS module class names for the row and its two spans (entry / entryIcon / entryLabel). */
62
- css: Record<string, string>
63
- /** Localized row label (aria-label + visible text). */
64
- label(): string
65
- /** Optional localized tooltip (title attribute). */
66
- tooltip?(): string
67
- /**
68
- * Optional locale-change subscription: re-applies label / aria-label /
69
- * tooltip whenever the active locale changes. Plain-DOM rows would
70
- * otherwise keep the label captured at mount; pass the SDK locale runtime
71
- * subscription (ctx.locale.subscribe) so the row follows the language
72
- * switch without a reload.
73
- */
74
- refresh?: { subscribe(listener: () => void): () => void }
75
- /** Click action (open/toggle the owning panel). */
76
- onToggle(): void
77
- /**
78
- * Optional trailing action buttons at the row's right edge. Requires the
79
- * package CSS to seat entryMain / entryAction; empty keeps the classic
80
- * single-button row.
81
- */
82
- actions?: readonly SidebarEntryAction[]
83
- /** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
84
- position: 'before' | 'after'
85
- /**
86
- * Selectors of the sibling plugin entry rows this package orders against
87
- * (its own row included — the placement guard excludes a row that is
88
- * already inside the root). Each package passes the same list it used
89
- * before the consolidation so the rendered order stays stable.
90
- */
91
- familySelectors: readonly string[]
92
- /** Optional active-state bridge; highlights the row while the panel is open. */
93
- active?: {
94
- subscribe(listener: () => void): () => void
95
- isOpen(): boolean
96
- }
97
- }
98
-
99
- /** Find the sidebar shell root element, or undefined while not yet mounted. */
100
- function sidebarRoot(): HTMLElement | undefined {
101
- const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
102
- if (column === null) return undefined
103
- // Current shells wrap the sidebar UI: column > wrapper > root(logoRow owner).
104
- // Prefer the element that owns the logo row — the real sidebar UI root —
105
- // and fall back to the column's first child for legacy shells.
106
- const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
107
- return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
108
- }
109
-
110
- /** The New Session button: nested in the logo row on current shells, a direct child on legacy shells. */
111
- function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
112
- const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
113
- if (nested !== null) return nested
114
- for (const child of root.children) {
115
- if (child.tagName === 'BUTTON') return child as HTMLButtonElement
116
- }
117
- return undefined
118
- }
119
-
120
- /** Build the entry row (detached; inserted once the shell is up). */
121
- function createEntry(options: SidebarEntryOptions): { entry: HTMLElement; applyLabel: () => void; setOpen: (open: boolean) => void } {
122
- const actions = options.actions ?? []
123
- const composite = actions.length > 0
124
- const entry = document.createElement(composite ? 'div' : 'button') as HTMLElement
125
- if (!composite) (entry as HTMLButtonElement).type = 'button'
126
- entry.setAttribute(options.rowAttribute, '')
127
- if (options.plugin !== undefined) {
128
- entry.setAttribute('data-dsh-plugin', options.plugin)
129
- entry.setAttribute('data-dsh-part', 'sidebar-entry')
130
- }
131
- entry.className = options.css['entry'] ?? ''
132
- const labelSpan = document.createElement('span')
133
- labelSpan.className = options.css['entryLabel'] ?? ''
134
- const iconSpan = document.createElement('span')
135
- iconSpan.className = options.css['entryIcon'] ?? ''
136
- iconSpan.innerHTML = options.icon
137
- // The main hit area: the classic row IS the button; with trailing actions
138
- // the row becomes a container and the main area its own button, so the row
139
- // never nests interactive elements.
140
- const main = composite ? document.createElement('button') : (entry as HTMLButtonElement)
141
- if (composite) {
142
- main.type = 'button'
143
- main.className = options.css['entryMain'] ?? ''
144
- main.append(iconSpan, labelSpan)
145
- entry.append(main)
146
- } else {
147
- entry.append(iconSpan, labelSpan)
148
- }
149
- const actionButtons: { action: SidebarEntryAction; button: HTMLButtonElement }[] = []
150
- for (const action of actions) {
151
- const button = document.createElement('button')
152
- button.type = 'button'
153
- button.className = options.css['entryAction'] ?? ''
154
- button.setAttribute('data-dsh-entry-action', action.id)
155
- button.innerHTML = action.icon
156
- button.addEventListener('click', () => { action.onClick(button) })
157
- entry.append(button)
158
- actionButtons.push({ action, button })
159
- }
160
- let open = false
161
- const applyActions = (): void => {
162
- for (const { action, button } of actionButtons) {
163
- button.innerHTML = open || action.inactiveIcon === undefined ? action.icon : action.inactiveIcon
164
- const text = action.label(open)
165
- button.setAttribute('aria-label', text)
166
- button.setAttribute('title', text)
167
- if (action.inactiveIcon !== undefined) button.setAttribute('aria-expanded', String(open))
168
- }
169
- }
170
- const applyLabel = (): void => {
171
- main.setAttribute('aria-label', options.label())
172
- if (options.tooltip !== undefined) main.setAttribute('title', options.tooltip())
173
- labelSpan.textContent = options.label()
174
- applyActions()
175
- }
176
- applyLabel()
177
- main.addEventListener('click', options.onToggle)
178
- if (composite) {
179
- // The old row was clickable edge to edge; keep the padding between the
180
- // main area and the action buttons a toggle target as well.
181
- entry.addEventListener('click', (event) => {
182
- if (event.target === entry) options.onToggle()
183
- })
184
- }
185
- return {
186
- entry,
187
- applyLabel,
188
- setOpen: (next: boolean) => {
189
- open = next
190
- applyActions()
191
- },
192
- }
193
- }
194
-
195
- /** Re-insert the entry after the New Session row (before the browser region). */
196
- function placeEntry(root: HTMLElement, entry: HTMLElement, options: SidebarEntryOptions): boolean {
197
- const button = newSessionButton(root)
198
- if (button === undefined) return false
199
- if (entry.parentElement !== root) {
200
- // Position relative to the family block (entries injected by sibling
201
- // plugins), never relative to transient logoRow geometry: every family
202
- // plugin that self-heals during a re-render then lands in the same
203
- // relative order, so the entries cannot swap positions regardless of
204
- // observer callback order or of shell wrapper changes. There is no
205
- // append-to-end fallback: appending at the end would randomly reorder
206
- // the block after a shell re-render.
207
- const row = button.closest('[class*="logoRow"]')
208
- const base = (row !== null && row.parentElement === root) ? row : button
209
- const family = Array.from(root.children).filter(
210
- (el): el is HTMLElement => el instanceof HTMLElement && el.matches(options.familySelectors.join(', ')),
211
- )
212
- const anchor = options.position === 'before'
213
- ? (family.length > 0 ? family[0] : base.nextElementSibling)
214
- : (family.length > 0 ? family[family.length - 1]!.nextElementSibling : base.nextElementSibling)
215
- root.insertBefore(entry, anchor)
216
- }
217
- return true
218
- }
219
-
220
- /**
221
- * Mount the sidebar entry, waiting for the shell to render and self-healing
222
- * on later React re-renders.
223
- * @param options - the row's attribute/icon/copy/action/ordering configuration.
224
- * @returns disposer removing the entry and its observers.
225
- */
226
- export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
227
- // DOM-level idempotency: whatever path mounted an entry row before this
228
- // call (a duplicated apply, an HMR re-injection, a stale module still
229
- // alive), never mount a second one. The existing row keeps working; a full
230
- // page reload is the ultimate reset.
231
- if (typeof document !== 'undefined' && document.querySelector(options.rowSelector) !== null) {
232
- return () => {}
233
- }
234
- const { entry, applyLabel, setOpen } = createEntry(options)
235
- let root: HTMLElement | undefined
236
- let placed = false
237
- let unsubscribeRefresh: (() => void) | undefined
238
- if (options.refresh !== undefined) {
239
- try {
240
- unsubscribeRefresh = options.refresh.subscribe(applyLabel)
241
- } catch {
242
- // A throwing subscription must not break the mount; the label stays at
243
- // its initial value and the next reload resolves it again.
244
- }
245
- }
246
-
247
- const tryPlace = (): void => {
248
- if (root !== undefined && !root.isConnected) {
249
- // The shell rebuilt the sidebar pane (whole-tree teardown); the root
250
- // observer is gone with the old tree, so detach it and re-query from
251
- // scratch. The new pane is later noticed by the body-level watcher.
252
- rootObserver.disconnect()
253
- root = undefined
254
- placed = false
255
- }
256
- if (placed) {
257
- // Cheap short-circuit: entry still lives in a mountable subtree.
258
- if (document.body.contains(entry)) return
259
- // Entry was torn down together with the old tree; reset and re-place.
260
- rootObserver.disconnect()
261
- root = undefined
262
- placed = false
263
- }
264
- root ??= sidebarRoot()
265
- if (root === undefined) return
266
- placed = placeEntry(root, entry, options)
267
- if (placed) {
268
- rootObserver.observe(root, { childList: true, subtree: true })
269
- }
270
- }
271
-
272
- // Body-level watcher retained as the "whole rebuild" fallback: when the shell
273
- // tears down the whole sidebar pane, the root observer is gone with it and
274
- // only this body observation can notice the new pane mounting. It stays
275
- // subscribed after placement; the placed-and-still-mounted case
276
- // short-circuits through the cheap document.body.contains(entry) check, so
277
- // unrelated app mutations (e.g. chat streaming) cost one contains check per
278
- // frame instead of churning the full re-query. The body observation itself is
279
- // the page-wide hub (shared/client/body-mutations.ts): every family plugin
280
- // used to hold its own body subtree observer, so the per-mutation cost grew
281
- // with the number of installed plugins; the hub keeps exactly one.
282
- const unsubscribeBody = subscribeBodyInvalidations(() => { tryPlace() })
283
-
284
- // Self-heal: if a React re-render displaces the row, re-insert it in the
285
- // same frame (microtask before paint -> no visible flicker).
286
- const rootObserver = new MutationObserver(() => {
287
- if (root === undefined || !root.isConnected) {
288
- placed = false
289
- tryPlace()
290
- return
291
- }
292
- if (!root.contains(entry)) {
293
- placed = placeEntry(root, entry, options)
294
- }
295
- })
296
-
297
- // Reflect the panel's open state on the row (active highlight). Note: assigning
298
- // undefined to dataset.active materializes data-active="undefined" and keeps the
299
- // row permanently highlighted — delete the attribute instead.
300
- const unsubscribeActive = options.active === undefined ? undefined : (() => {
301
- const syncActive = (): void => {
302
- const open = options.active!.isOpen()
303
- if (open) entry.dataset.active = 'true'
304
- else delete entry.dataset.active
305
- setOpen(open)
306
- }
307
- const unsubscribe = options.active.subscribe(syncActive)
308
- syncActive()
309
- return unsubscribe
310
- })()
311
-
312
- tryPlace()
313
-
314
- return () => {
315
- unsubscribeBody()
316
- rootObserver.disconnect()
317
- unsubscribeRefresh?.()
318
- unsubscribeActive?.()
319
- entry.remove()
320
- }
321
- }