@shendeguize/dsh-agent-sidecar 0.1.1 → 0.2.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.md +78 -11
- package/lib/client.js +8659 -7589
- package/lib/client.js.map +1 -1
- package/package.json +3 -1
- package/src/client/analysis/AnalysisPanel.tsx +19 -27
- package/src/client/analysis/analysis.module.css +36 -97
- package/src/client/board/Board.tsx +115 -61
- package/src/client/board/board.module.css +72 -151
- package/src/client/board/project-view-logic.ts +21 -34
- package/src/client/board/project-view.module.css +41 -96
- package/src/client/board/project-view.tsx +29 -13
- package/src/client/board/strings.ts +68 -107
- package/src/client/commands.ts +30 -15
- package/src/client/detail/SessionDetail.tsx +92 -58
- package/src/client/detail/detail.module.css +62 -218
- package/src/client/detail/strings.ts +64 -88
- package/src/client/detail-view.module.css +30 -55
- package/src/client/detail-view.tsx +80 -108
- package/src/client/dsh-tools/LineageTree.tsx +22 -13
- package/src/client/dsh-tools/SearchPanel.tsx +18 -11
- package/src/client/dsh-tools/dsh-tools.module.css +50 -139
- package/src/client/dsh-tools/strings.ts +42 -77
- package/src/client/index.ts +285 -124
- package/src/client/inject/InjectPanel.tsx +31 -64
- package/src/client/inject/inject.module.css +97 -198
- package/src/client/lifecycle/handoff.ts +83 -0
- package/src/client/locales/en.ts +74 -2
- package/src/client/locales/host.ts +131 -0
- package/src/client/locales/index.ts +196 -8
- package/src/client/locales/react.ts +10 -0
- package/src/client/locales/view.ts +38 -0
- package/src/client/locales/zh.ts +194 -134
- package/src/client/mount.tsx +72 -38
- package/src/client/navigation/CenterOverlay.tsx +40 -0
- package/src/client/navigation/center-overlay.module.css +51 -0
- package/src/client/navigation/center.ts +45 -0
- package/src/client/navigation/modal-isolation.ts +152 -0
- package/src/client/navigation/modal-surface-anchor.ts +72 -0
- package/src/client/navigation/sidebar-entry.module.css +73 -0
- package/src/client/navigation/sidebar-entry.ts +309 -0
- package/src/client/primitives/StaticPill.tsx +21 -0
- package/src/client/settings-card.module.css +35 -119
- package/src/client/settings-card.tsx +31 -153
- package/src/client/settings-fields.tsx +131 -0
- package/src/client/sidebar/SidebarTab.tsx +156 -0
- package/src/client/sidebar/model.ts +65 -0
- package/src/client/sidebar/sidebar-tab.module.css +118 -0
- package/src/client/sidebar-tab.tsx +46 -320
- package/src/client/theme/agsc.module.css +31 -0
- package/src/client/theme/parts.ts +56 -0
- package/src/client/ui-integration.ts +86 -0
- package/src/client/widget.tsx +9 -8
- package/src/client/inject/overlay.module.css +0 -22
package/src/client/locales/en.ts
CHANGED
|
@@ -170,9 +170,79 @@ export const en = {
|
|
|
170
170
|
'inject.errParse': 'The server response could not be parsed.',
|
|
171
171
|
'inject.errGeneric': 'Request failed ({code}).',
|
|
172
172
|
|
|
173
|
-
// ── board tab chrome
|
|
173
|
+
// ── board tab chrome + session board ──────────────────────────────────
|
|
174
174
|
'board.viewBoard': 'Session board',
|
|
175
175
|
'board.viewProjects': 'Projects',
|
|
176
|
+
'board.status.working': 'Working',
|
|
177
|
+
'board.status.waiting': 'Waiting',
|
|
178
|
+
'board.status.idle': 'Idle',
|
|
179
|
+
'board.status.dead': 'Finished',
|
|
180
|
+
'board.status.unknown': 'Unknown',
|
|
181
|
+
'board.attention.gap': 'Event gap',
|
|
182
|
+
'board.daemon.probe': 'Probing',
|
|
183
|
+
'board.daemon.adopted': 'Connected · adopted',
|
|
184
|
+
'board.daemon.defer': 'Waiting for the system service',
|
|
185
|
+
'board.daemon.reprobe': 'Re-probing',
|
|
186
|
+
'board.daemon.hosting': 'Starting',
|
|
187
|
+
'board.daemon.hosted': 'Connected · hosted',
|
|
188
|
+
'board.daemon.backoff': 'Restart backoff',
|
|
189
|
+
'board.daemon.failed': 'Offline',
|
|
190
|
+
'board.stream.ok': 'Live stream healthy',
|
|
191
|
+
'board.stream.degraded': 'Live stream reconnecting',
|
|
192
|
+
'board.stream.unknown': 'Live stream not connected',
|
|
193
|
+
'board.banner.daemonFailed':
|
|
194
|
+
'sidecar is offline: the board shows the last snapshot and will not update',
|
|
195
|
+
'board.banner.streamDegraded': 'The live stream is reconnecting; data may be stale',
|
|
196
|
+
'board.empty.daemonFailedTitle': 'sidecar is offline',
|
|
197
|
+
'board.empty.daemonFailedHint':
|
|
198
|
+
'Repeated daemon start failures tripped the circuit breaker. Retry from Settings, or run agent-sidecar daemon start and wait for automatic adoption.',
|
|
199
|
+
'board.empty.daemonDeferTitle': 'Waiting for the system service to start the daemon',
|
|
200
|
+
'board.empty.daemonDeferHint':
|
|
201
|
+
'A LaunchAgent manages the daemon, so the plugin will not start it. The board populates automatically once the service starts it.',
|
|
202
|
+
'board.empty.filteredTitle': 'No sessions match these filters',
|
|
203
|
+
'board.empty.filteredHint':
|
|
204
|
+
'Try a wider time window, clear the status filter, or enable "Show finished".',
|
|
205
|
+
'board.empty.noSessionsTitle': 'No observed sessions yet',
|
|
206
|
+
'board.empty.noSessionsHint':
|
|
207
|
+
'Local agents (claude / codex / cursor / dsh …) appear here automatically once they start working.',
|
|
208
|
+
'board.topbar.title': 'Sidecar multi-agent board',
|
|
209
|
+
'board.topbar.refresh': 'Refresh',
|
|
210
|
+
'board.topbar.refreshing': 'Refreshing…',
|
|
211
|
+
'board.topbar.refreshTitle': 'Fetch the latest snapshot',
|
|
212
|
+
'board.topbar.refreshFailed': 'Refresh failed; the board is still showing the previous snapshot',
|
|
213
|
+
'board.topbar.dismiss': 'Dismiss',
|
|
214
|
+
'board.topbar.showDead': 'Show finished',
|
|
215
|
+
'board.topbar.timeWindow': 'Time window',
|
|
216
|
+
'board.topbar.countWorking': '{n} working',
|
|
217
|
+
'board.topbar.countWaiting': '{n} waiting',
|
|
218
|
+
'board.topbar.countTotal': '{n} sessions total',
|
|
219
|
+
'board.topbar.filterByStatusTitle': 'Show only sessions marked "{label}"',
|
|
220
|
+
'board.topbar.clearStatusFilterTitle': 'Clear the status filter and show all sessions',
|
|
221
|
+
'board.group.collapseTitle': 'Collapse this group',
|
|
222
|
+
'board.group.expandTitle': 'Expand this group',
|
|
223
|
+
'board.group.showAll': 'Show all {n} sessions',
|
|
224
|
+
'board.group.showLess': 'Show first {n} only',
|
|
225
|
+
'board.card.noEvent': 'No events yet',
|
|
226
|
+
'board.card.untitled': '(untitled)',
|
|
227
|
+
'board.card.observedDisclaimer':
|
|
228
|
+
'Status is an observed value inferred from persisted data and may lag',
|
|
229
|
+
'board.card.observedValue': 'Observed value: {status}',
|
|
230
|
+
'board.card.lastReconcile': 'Last reconciled: {time}',
|
|
231
|
+
'board.card.neverReconciled': 'Not reconciled yet',
|
|
232
|
+
'board.card.copyId': 'Click to copy the full session ID',
|
|
233
|
+
'board.card.copied': 'Copied',
|
|
234
|
+
'board.time.justNow': 'just now',
|
|
235
|
+
'board.time.minutesAgo': '{n} min ago',
|
|
236
|
+
'board.time.hoursAgo': '{n} h ago',
|
|
237
|
+
'board.timeWindow.hours': '{n} hours',
|
|
238
|
+
'board.timeWindow.days': '{n} days',
|
|
239
|
+
'board.groupCount': '{n} sessions',
|
|
240
|
+
'board.unknownProject': 'Unknown project',
|
|
241
|
+
'board.widget.label': 'Sidecar',
|
|
242
|
+
'board.widget.connection.ok': 'Connected',
|
|
243
|
+
'board.widget.connection.degraded': 'Connection unstable',
|
|
244
|
+
'board.widget.connection.off': 'Offline',
|
|
245
|
+
'board.widget.working': '{n} sessions working',
|
|
176
246
|
|
|
177
247
|
// ── session detail ───────────────────────────────────────────────────
|
|
178
248
|
'detail.header.close': 'Back to board',
|
|
@@ -388,7 +458,9 @@ export const en = {
|
|
|
388
458
|
'command.time.hoursAgo': '{n} h ago',
|
|
389
459
|
'command.time.daysAgo': '{n} d ago',
|
|
390
460
|
|
|
391
|
-
// ──
|
|
461
|
+
// ── sidebar navigation (plain DOM + optional better-sidebar) ───────────
|
|
462
|
+
'sidebar.centerEntryLabel': 'Agent Center',
|
|
463
|
+
'sidebar.centerEntryAria': 'Open Agent Center',
|
|
392
464
|
'sidebar.tabTitle': 'Sidecar',
|
|
393
465
|
'sidebar.countsRow': '{working} working · {waiting} waiting',
|
|
394
466
|
'sidebar.recentTitle': 'Recently active',
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability-based bridge to the optional dsh locale service.
|
|
3
|
+
*
|
|
4
|
+
* API evidence:
|
|
5
|
+
* `.local/reference/deepseek-harness/packages/client/locale/src/client/index.ts`
|
|
6
|
+
* exposes `getLocale().active`, `register(ns, locale, dict)`, and the
|
|
7
|
+
* context event `locale/change`. This module mirrors only those shapes; it
|
|
8
|
+
* deliberately imports no unpublished host runtime value.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Locale ids shipped by Agent Sidecar. */
|
|
12
|
+
export type HostMappedLocale = 'zh' | 'en'
|
|
13
|
+
|
|
14
|
+
/** Flat namespace dictionary accepted by the host registry. */
|
|
15
|
+
export type HostLocaleDictionary = Readonly<Record<string, string>>
|
|
16
|
+
|
|
17
|
+
/** Locale-like values accepted from getters and change events. */
|
|
18
|
+
export interface HostLocaleSnapshot {
|
|
19
|
+
active?: string
|
|
20
|
+
locale?: string
|
|
21
|
+
preference?: string
|
|
22
|
+
value?: { preference?: string }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type HostLocaleValue = string | HostLocaleSnapshot | null | undefined
|
|
26
|
+
export type HostLocaleDisposer = () => void
|
|
27
|
+
|
|
28
|
+
/** Optional locale service capabilities used by the bridge. */
|
|
29
|
+
export interface HostLocaleService {
|
|
30
|
+
getLocale?: () => HostLocaleValue
|
|
31
|
+
register?: (
|
|
32
|
+
namespace: string,
|
|
33
|
+
locale: string,
|
|
34
|
+
dictionary: HostLocaleDictionary,
|
|
35
|
+
) => void | HostLocaleDisposer
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Optional host-context capabilities used by the bridge. */
|
|
39
|
+
export interface HostLocalePort {
|
|
40
|
+
locale?: HostLocaleService
|
|
41
|
+
on?: (
|
|
42
|
+
event: 'locale/change',
|
|
43
|
+
listener: (value?: HostLocaleValue) => void,
|
|
44
|
+
) => void | HostLocaleDisposer
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Injected policy and data; keeps the adapter independent of locale state. */
|
|
48
|
+
export interface HostLocaleBridgeOptions {
|
|
49
|
+
namespace: string
|
|
50
|
+
dictionaries: Readonly<Record<HostMappedLocale, HostLocaleDictionary>>
|
|
51
|
+
onLocale: (locale: HostMappedLocale) => void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function localeTag(value: HostLocaleValue): string | undefined {
|
|
55
|
+
if (typeof value === 'string') return value
|
|
56
|
+
if (value === null || value === undefined) return undefined
|
|
57
|
+
return value.active
|
|
58
|
+
?? value.preference
|
|
59
|
+
?? value.locale
|
|
60
|
+
?? value.value?.preference
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Map every Chinese locale variant to zh; all other values use en. */
|
|
64
|
+
export function mapHostLocale(value: HostLocaleValue): HostMappedLocale {
|
|
65
|
+
return localeTag(value)?.trim().toLowerCase().startsWith('zh') === true ? 'zh' : 'en'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Register dictionaries, adopt the current Host locale, and follow changes.
|
|
70
|
+
* Every capability is optional and isolated: absent or throwing Host methods
|
|
71
|
+
* leave the module-owned locale fallback intact.
|
|
72
|
+
*/
|
|
73
|
+
export function bridgeHostLocale(
|
|
74
|
+
host: HostLocalePort | null | undefined,
|
|
75
|
+
options: HostLocaleBridgeOptions,
|
|
76
|
+
): HostLocaleDisposer {
|
|
77
|
+
const disposers: HostLocaleDisposer[] = []
|
|
78
|
+
const service = host?.locale
|
|
79
|
+
|
|
80
|
+
for (const locale of ['zh', 'en'] as const) {
|
|
81
|
+
try {
|
|
82
|
+
const dispose = service?.register?.(
|
|
83
|
+
options.namespace,
|
|
84
|
+
locale,
|
|
85
|
+
options.dictionaries[locale],
|
|
86
|
+
)
|
|
87
|
+
if (typeof dispose === 'function') disposers.push(dispose)
|
|
88
|
+
} catch {
|
|
89
|
+
// Optional/duplicate registry seats must not block the local fallback.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const read = (): HostLocaleValue => {
|
|
94
|
+
try {
|
|
95
|
+
return service?.getLocale?.()
|
|
96
|
+
} catch {
|
|
97
|
+
return undefined
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const apply = (value: HostLocaleValue): void => {
|
|
101
|
+
if (localeTag(value) === undefined) return
|
|
102
|
+
try {
|
|
103
|
+
options.onLocale(mapHostLocale(value))
|
|
104
|
+
} catch {
|
|
105
|
+
// A consumer failure must not escape a Host event callback.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const dispose = host?.on?.('locale/change', (value) => {
|
|
111
|
+
apply(value === undefined ? read() : value)
|
|
112
|
+
})
|
|
113
|
+
if (typeof dispose === 'function') disposers.push(dispose)
|
|
114
|
+
} catch {
|
|
115
|
+
// Event capability is optional.
|
|
116
|
+
}
|
|
117
|
+
apply(read())
|
|
118
|
+
|
|
119
|
+
let disposed = false
|
|
120
|
+
return () => {
|
|
121
|
+
if (disposed) return
|
|
122
|
+
disposed = true
|
|
123
|
+
for (const dispose of disposers.reverse()) {
|
|
124
|
+
try {
|
|
125
|
+
dispose()
|
|
126
|
+
} catch {
|
|
127
|
+
// Teardown remains best-effort across independently owned services.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -14,29 +14,47 @@
|
|
|
14
14
|
* visible, never blank — same posture as the ecosystem LocaleRuntime,
|
|
15
15
|
* which falls back to en; ours falls back to zh per this plugin's spec).
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* it and hand the slot-injected `t` seat to the components instead of the
|
|
23
|
-
* module-local {@link t}. Template params use the same `{name}` syntax as
|
|
17
|
+
* The optional Host bridge in ./host.ts registers these flat dictionaries
|
|
18
|
+
* through the ecosystem locale service's untyped
|
|
19
|
+
* `register(ns, locale, dict)` overload and mirrors `locale/change` into
|
|
20
|
+
* this module's active locale. The local table remains the fallback whenever
|
|
21
|
+
* that service is absent. Template params use the same `{name}` syntax as
|
|
24
22
|
* the ecosystem translate.
|
|
25
23
|
*/
|
|
26
24
|
|
|
27
25
|
import { zh } from './zh.ts'
|
|
28
26
|
import { en } from './en.ts'
|
|
27
|
+
import {
|
|
28
|
+
bridgeHostLocale as bridgeHostLocalePort,
|
|
29
|
+
type HostLocalePort as HostLocalePortFace,
|
|
30
|
+
type HostLocaleService as HostLocaleServiceFace,
|
|
31
|
+
type HostMappedLocale as HostMappedLocaleId,
|
|
32
|
+
} from './host.ts'
|
|
29
33
|
import type { SidecarLocaleDomain, SidecarLocaleKey } from './zh.ts'
|
|
30
34
|
|
|
31
35
|
export { zh, en }
|
|
32
36
|
export type { SidecarLocaleDomain, SidecarLocaleKey }
|
|
37
|
+
export { bridgeHostLocale, mapHostLocale } from './host.ts'
|
|
38
|
+
export type {
|
|
39
|
+
HostLocaleBridgeOptions,
|
|
40
|
+
HostLocaleDictionary,
|
|
41
|
+
HostLocaleDisposer,
|
|
42
|
+
HostLocalePort,
|
|
43
|
+
HostLocaleService,
|
|
44
|
+
HostLocaleSnapshot,
|
|
45
|
+
HostLocaleValue,
|
|
46
|
+
HostMappedLocale,
|
|
47
|
+
} from './host.ts'
|
|
33
48
|
|
|
34
49
|
/** Shipped locales. */
|
|
35
|
-
export type SidecarLocale =
|
|
50
|
+
export type SidecarLocale = HostMappedLocaleId
|
|
36
51
|
|
|
37
52
|
/** Default locale AND the final dictionary consulted before echoing the key. */
|
|
38
53
|
export const BASE_LOCALE: SidecarLocale = 'zh'
|
|
39
54
|
|
|
55
|
+
/** Host namespace owned by Agent Sidecar dictionaries. */
|
|
56
|
+
export const SIDECAR_LOCALE_NAMESPACE = 'agent-sidecar'
|
|
57
|
+
|
|
40
58
|
/** One flat dictionary (the `ctx.locale.register(ns, locale, dict)` currency). */
|
|
41
59
|
export type SidecarDict = Readonly<Record<string, string>>
|
|
42
60
|
|
|
@@ -111,6 +129,176 @@ export function subscribeLocale(fn: () => void): () => void {
|
|
|
111
129
|
return () => { localeListeners.delete(fn) }
|
|
112
130
|
}
|
|
113
131
|
|
|
132
|
+
interface HostLocaleOwner {
|
|
133
|
+
host: HostLocalePortFace
|
|
134
|
+
service: HostLocaleServiceFace
|
|
135
|
+
onLocale: (locale: SidecarLocale) => void
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface HostLocaleLease {
|
|
139
|
+
owners: Set<HostLocaleOwner>
|
|
140
|
+
detachDictionaries: () => void
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface HostLocaleLeaseRegistry {
|
|
144
|
+
get(service: HostLocaleServiceFace): HostLocaleLease | undefined
|
|
145
|
+
set(service: HostLocaleServiceFace, lease: HostLocaleLease): unknown
|
|
146
|
+
delete(service: HostLocaleServiceFace): boolean
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
interface HostLocaleOwnerSet {
|
|
150
|
+
readonly size: number
|
|
151
|
+
add(owner: HostLocaleOwner): unknown
|
|
152
|
+
delete(owner: HostLocaleOwner): boolean
|
|
153
|
+
[Symbol.iterator](): IterableIterator<HostLocaleOwner>
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
interface HostLocaleArbiter {
|
|
157
|
+
leases: HostLocaleLeaseRegistry
|
|
158
|
+
owners: HostLocaleOwnerSet
|
|
159
|
+
activeOwner: HostLocaleOwner | null
|
|
160
|
+
detachEvents: () => void
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const HOST_LOCALE_LEASES_SYMBOL = Symbol.for(
|
|
164
|
+
'@shendeguize/dsh-agent-sidecar/host-locale-leases',
|
|
165
|
+
)
|
|
166
|
+
const globalSymbols = globalThis as typeof globalThis & { [key: symbol]: unknown }
|
|
167
|
+
|
|
168
|
+
function hasMethod<K extends PropertyKey>(
|
|
169
|
+
value: unknown,
|
|
170
|
+
key: K,
|
|
171
|
+
): value is Record<K, (...args: never[]) => unknown> {
|
|
172
|
+
return typeof value === 'object'
|
|
173
|
+
&& value !== null
|
|
174
|
+
&& typeof (value as Record<K, unknown>)[key] === 'function'
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isLeaseRegistry(value: unknown): value is HostLocaleLeaseRegistry {
|
|
178
|
+
return hasMethod(value, 'get') && hasMethod(value, 'set') && hasMethod(value, 'delete')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isOwnerSet(value: unknown): value is HostLocaleOwnerSet {
|
|
182
|
+
return hasMethod(value, 'add')
|
|
183
|
+
&& hasMethod(value, 'delete')
|
|
184
|
+
&& hasMethod(value, Symbol.iterator)
|
|
185
|
+
&& typeof (value as { size?: unknown }).size === 'number'
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isHostLocaleArbiter(value: unknown): value is HostLocaleArbiter {
|
|
189
|
+
if (typeof value !== 'object' || value === null) return false
|
|
190
|
+
const candidate = value as Partial<HostLocaleArbiter>
|
|
191
|
+
return isLeaseRegistry(candidate.leases)
|
|
192
|
+
&& isOwnerSet(candidate.owners)
|
|
193
|
+
&& (candidate.activeOwner === null || typeof candidate.activeOwner === 'object')
|
|
194
|
+
&& typeof candidate.detachEvents === 'function'
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const sharedLocaleState = globalSymbols[HOST_LOCALE_LEASES_SYMBOL]
|
|
198
|
+
const hostLocaleArbiter: HostLocaleArbiter = isHostLocaleArbiter(sharedLocaleState)
|
|
199
|
+
? sharedLocaleState
|
|
200
|
+
: {
|
|
201
|
+
// Preserve a pre-arbiter WeakMap-like registry across an HMR upgrade.
|
|
202
|
+
leases: isLeaseRegistry(sharedLocaleState)
|
|
203
|
+
? sharedLocaleState
|
|
204
|
+
: new WeakMap<HostLocaleServiceFace, HostLocaleLease>(),
|
|
205
|
+
owners: new Set<HostLocaleOwner>(),
|
|
206
|
+
activeOwner: null,
|
|
207
|
+
detachEvents: () => {},
|
|
208
|
+
}
|
|
209
|
+
globalSymbols[HOST_LOCALE_LEASES_SYMBOL] = hostLocaleArbiter
|
|
210
|
+
|
|
211
|
+
function bridge(
|
|
212
|
+
host: HostLocalePortFace,
|
|
213
|
+
onLocale: (locale: SidecarLocale) => void,
|
|
214
|
+
): () => void {
|
|
215
|
+
return bridgeHostLocalePort(host, {
|
|
216
|
+
namespace: SIDECAR_LOCALE_NAMESPACE,
|
|
217
|
+
dictionaries,
|
|
218
|
+
onLocale,
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function followOwner(owner: HostLocaleOwner): () => void {
|
|
223
|
+
return bridge({
|
|
224
|
+
locale: { getLocale: () => owner.service.getLocale?.() },
|
|
225
|
+
on: (event, listener) => owner.host.on?.(event, listener),
|
|
226
|
+
}, locale => {
|
|
227
|
+
if (hostLocaleArbiter.activeOwner === owner) owner.onLocale(locale)
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function latestOwner(owners: HostLocaleOwnerSet): HostLocaleOwner {
|
|
232
|
+
let latest: HostLocaleOwner | undefined
|
|
233
|
+
for (const owner of owners) latest = owner
|
|
234
|
+
return latest as HostLocaleOwner
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function detachActiveFollower(): void {
|
|
238
|
+
const detach = hostLocaleArbiter.detachEvents
|
|
239
|
+
hostLocaleArbiter.detachEvents = () => {}
|
|
240
|
+
try {
|
|
241
|
+
detach()
|
|
242
|
+
} catch {
|
|
243
|
+
// Cross-bundle optional Host teardown remains best-effort.
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function activateOwner(owner: HostLocaleOwner): void {
|
|
248
|
+
detachActiveFollower()
|
|
249
|
+
hostLocaleArbiter.activeOwner = owner
|
|
250
|
+
hostLocaleArbiter.detachEvents = followOwner(owner)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Lease the shipped dictionaries and locale following for one Host service.
|
|
255
|
+
* Dictionary ownership is keyed by service identity. Event ownership is
|
|
256
|
+
* global across services and bundles so only the latest attached owner can
|
|
257
|
+
* drive its module-local locale.
|
|
258
|
+
*/
|
|
259
|
+
export function attachHostLocale(host: HostLocalePortFace | null | undefined): () => void {
|
|
260
|
+
if (host?.locale === undefined) return () => {}
|
|
261
|
+
const service = host.locale
|
|
262
|
+
|
|
263
|
+
const owner: HostLocaleOwner = { host, service, onLocale: setLocale }
|
|
264
|
+
let lease = hostLocaleArbiter.leases.get(service)
|
|
265
|
+
if (lease === undefined) {
|
|
266
|
+
lease = {
|
|
267
|
+
owners: new Set<HostLocaleOwner>(),
|
|
268
|
+
// Registration must never choose this module's active locale.
|
|
269
|
+
detachDictionaries: bridge({ locale: service }, () => {}),
|
|
270
|
+
}
|
|
271
|
+
hostLocaleArbiter.leases.set(service, lease)
|
|
272
|
+
}
|
|
273
|
+
lease.owners.add(owner)
|
|
274
|
+
hostLocaleArbiter.owners.add(owner)
|
|
275
|
+
activateOwner(owner)
|
|
276
|
+
|
|
277
|
+
let disposed = false
|
|
278
|
+
return () => {
|
|
279
|
+
if (disposed) return
|
|
280
|
+
disposed = true
|
|
281
|
+
const wasActive = hostLocaleArbiter.activeOwner === owner
|
|
282
|
+
lease.owners.delete(owner)
|
|
283
|
+
hostLocaleArbiter.owners.delete(owner)
|
|
284
|
+
if (wasActive) {
|
|
285
|
+
detachActiveFollower()
|
|
286
|
+
hostLocaleArbiter.activeOwner = null
|
|
287
|
+
}
|
|
288
|
+
if (lease.owners.size === 0) {
|
|
289
|
+
lease.detachDictionaries()
|
|
290
|
+
hostLocaleArbiter.leases.delete(service)
|
|
291
|
+
}
|
|
292
|
+
if (!wasActive) return
|
|
293
|
+
|
|
294
|
+
if (hostLocaleArbiter.owners.size > 0) {
|
|
295
|
+
activateOwner(latestOwner(hostLocaleArbiter.owners))
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
owner.onLocale(BASE_LOCALE)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
114
302
|
/**
|
|
115
303
|
* Translate a typed key in the active locale. Missing entries fall back to
|
|
116
304
|
* zh, then to the key itself (see module doc for why the chain ends visible).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react'
|
|
2
|
+
import { getLocale, subscribeLocale, type SidecarLocale } from './index.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Subscribe the calling React root to the module-owned active locale.
|
|
6
|
+
* Reading translations remains late-bound through `t` and locale facades.
|
|
7
|
+
*/
|
|
8
|
+
export function useActiveLocale(): SidecarLocale {
|
|
9
|
+
return useSyncExternalStore(subscribeLocale, getLocale, getLocale)
|
|
10
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { t, type SidecarLocaleKey } from './index.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A nested map from view-facing names to keys in the shipped locale table.
|
|
5
|
+
* Leaves are deliberately parameterless; callers format translated templates.
|
|
6
|
+
*/
|
|
7
|
+
export type LocaleViewDescriptor = Readonly<{
|
|
8
|
+
[name: string]: SidecarLocaleKey | LocaleViewDescriptor
|
|
9
|
+
}>
|
|
10
|
+
|
|
11
|
+
/** Preserve a descriptor's readonly shape while translating every leaf. */
|
|
12
|
+
export type LocaleView<D extends LocaleViewDescriptor> = {
|
|
13
|
+
readonly [K in keyof D]:
|
|
14
|
+
D[K] extends SidecarLocaleKey ? string
|
|
15
|
+
: D[K] extends LocaleViewDescriptor ? LocaleView<D[K]>
|
|
16
|
+
: never
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function buildNode(descriptor: LocaleViewDescriptor): Record<string, unknown> {
|
|
20
|
+
const view: Record<string, unknown> = {}
|
|
21
|
+
for (const [name, value] of Object.entries(descriptor)) {
|
|
22
|
+
const property: PropertyDescriptor = typeof value === 'string'
|
|
23
|
+
? { enumerable: true, get: () => t(value) }
|
|
24
|
+
: { enumerable: true, value: buildNode(value) }
|
|
25
|
+
Object.defineProperty(view, name, property)
|
|
26
|
+
}
|
|
27
|
+
return view
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build one stable, enumerable locale facade. Leaf getters call {@link t} at
|
|
32
|
+
* read time, so the same facade follows subsequent active-locale changes.
|
|
33
|
+
*/
|
|
34
|
+
export function createLocaleView<const D extends LocaleViewDescriptor>(
|
|
35
|
+
descriptor: D,
|
|
36
|
+
): LocaleView<D> {
|
|
37
|
+
return buildNode(descriptor) as LocaleView<D>
|
|
38
|
+
}
|