@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.
- package/README.i18n.yaml +2 -2
- package/README.md +4 -4
- package/README.zh.md +4 -4
- package/icon.svg +3 -0
- package/lib/client.js +540 -655
- package/lib/client.js.map +1 -1
- package/lib/index.js +92 -75
- package/lib/types/client/UsageFootCard.d.ts +55 -0
- package/lib/types/client/UsageSectionCard.d.ts +3 -3
- package/lib/types/client/foot-card-mount.d.ts +34 -0
- package/lib/types/client/index.d.ts +21 -8
- package/lib/types/client/locales.d.ts +6 -9
- package/lib/types/host/http.d.ts +18 -1
- package/lib/types/host/usage-service.d.ts +4 -3
- package/lib/types/index.d.ts +14 -0
- package/package.json +19 -17
- package/src/client/UsageFootCard.tsx +300 -0
- package/src/client/UsageSectionCard.tsx +41 -9
- package/src/client/foot-card-mount.tsx +112 -0
- package/src/client/index.ts +53 -33
- package/src/client/locales.ts +12 -18
- package/src/client/usage.module.css +101 -165
- package/src/host/http.ts +23 -1
- package/src/host/usage-service.ts +9 -4
- package/src/index.ts +59 -55
- package/lib/types/client/UsageSidebarPanel.d.ts +0 -34
- package/lib/types/client/sidebar-entry-core.d.ts +0 -87
- package/lib/types/client/sidebar-entry.d.ts +0 -35
- package/lib/types/client/sidebar-panel-mount.d.ts +0 -40
- package/src/client/UsageSidebarPanel.tsx +0 -196
- package/src/client/sidebar-entry-core.ts +0 -321
- package/src/client/sidebar-entry.ts +0 -101
- package/src/client/sidebar-panel-mount.tsx +0 -113
|
@@ -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
|
-
}
|