@shendeguize/dsh-agent-sidecar 0.1.0 → 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.
Files changed (58) hide show
  1. package/README.md +78 -11
  2. package/lib/client.js +4708 -2932
  3. package/lib/client.js.map +1 -1
  4. package/package.json +3 -1
  5. package/src/client/analysis/AnalysisPanel.tsx +19 -27
  6. package/src/client/analysis/analysis.module.css +36 -97
  7. package/src/client/board/Board.tsx +255 -48
  8. package/src/client/board/board.module.css +113 -92
  9. package/src/client/board/logic.ts +99 -21
  10. package/src/client/board/project-view-logic.ts +27 -34
  11. package/src/client/board/project-view.module.css +46 -83
  12. package/src/client/board/project-view.tsx +50 -8
  13. package/src/client/board/strings.ts +70 -85
  14. package/src/client/commands.ts +30 -15
  15. package/src/client/controller.ts +27 -7
  16. package/src/client/detail/SessionDetail.tsx +234 -37
  17. package/src/client/detail/detail.module.css +100 -153
  18. package/src/client/detail/logic.ts +220 -29
  19. package/src/client/detail/strings.ts +66 -77
  20. package/src/client/detail-glue.ts +33 -0
  21. package/src/client/detail-view.module.css +43 -35
  22. package/src/client/detail-view.tsx +138 -118
  23. package/src/client/dsh-tools/LineageTree.tsx +22 -13
  24. package/src/client/dsh-tools/SearchPanel.tsx +18 -11
  25. package/src/client/dsh-tools/dsh-tools.module.css +53 -140
  26. package/src/client/dsh-tools/strings.ts +42 -77
  27. package/src/client/index.ts +285 -124
  28. package/src/client/inject/InjectPanel.tsx +81 -61
  29. package/src/client/inject/inject.module.css +97 -197
  30. package/src/client/inject/logic.ts +38 -0
  31. package/src/client/lifecycle/handoff.ts +83 -0
  32. package/src/client/locales/en.ts +91 -4
  33. package/src/client/locales/host.ts +131 -0
  34. package/src/client/locales/index.ts +196 -8
  35. package/src/client/locales/react.ts +10 -0
  36. package/src/client/locales/view.ts +38 -0
  37. package/src/client/locales/zh.ts +200 -125
  38. package/src/client/mount.tsx +75 -41
  39. package/src/client/navigation/CenterOverlay.tsx +40 -0
  40. package/src/client/navigation/center-overlay.module.css +51 -0
  41. package/src/client/navigation/center.ts +45 -0
  42. package/src/client/navigation/modal-isolation.ts +152 -0
  43. package/src/client/navigation/modal-surface-anchor.ts +72 -0
  44. package/src/client/navigation/sidebar-entry.module.css +73 -0
  45. package/src/client/navigation/sidebar-entry.ts +309 -0
  46. package/src/client/primitives/StaticPill.tsx +21 -0
  47. package/src/client/settings-card.module.css +35 -119
  48. package/src/client/settings-card.tsx +31 -153
  49. package/src/client/settings-fields.tsx +131 -0
  50. package/src/client/sidebar/SidebarTab.tsx +156 -0
  51. package/src/client/sidebar/model.ts +65 -0
  52. package/src/client/sidebar/sidebar-tab.module.css +118 -0
  53. package/src/client/sidebar-tab.tsx +46 -320
  54. package/src/client/theme/agsc.module.css +31 -0
  55. package/src/client/theme/parts.ts +56 -0
  56. package/src/client/ui-integration.ts +86 -0
  57. package/src/client/widget.tsx +42 -16
  58. package/src/client/inject/overlay.module.css +0 -22
@@ -69,7 +69,7 @@ export const en = {
69
69
  'settings.sectionInject': 'Message injection',
70
70
  'settings.injectEnabledLabel': 'Enable injection',
71
71
  'settings.injectEnabledHint':
72
- 'When off, the board hides every inject affordance and the server rejects write actions.',
72
+ 'When off, the inject panel renders read-only and disabled, and the server rejects write actions.',
73
73
  'settings.injectDefaultModeLabel': 'Default injection mode',
74
74
  'settings.injectDefaultModeHint': 'The mode preselected when the inject panel opens.',
75
75
  'settings.injectModeQueue': 'queue (next turn)',
@@ -147,6 +147,7 @@ export const en = {
147
147
  'inject.resultReplayed':
148
148
  'Idempotent replay: this is the earlier result of the same request — no second injection happened.',
149
149
  'inject.reprepare': 'Prepare again',
150
+ 'inject.observeListen': 'Listen for the reaction',
150
151
 
151
152
  // ── inject panel: error vocabulary (gateway + transport) ─────────────
152
153
  'inject.errInjectDisabled': 'Injection is disabled on the server; enable it in Settings.',
@@ -169,15 +170,90 @@ export const en = {
169
170
  'inject.errParse': 'The server response could not be parsed.',
170
171
  'inject.errGeneric': 'Request failed ({code}).',
171
172
 
172
- // ── board tab chrome: main-view switcher ───────────────────────────────
173
+ // ── board tab chrome + session board ──────────────────────────────────
173
174
  'board.viewBoard': 'Session board',
174
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',
175
246
 
176
247
  // ── session detail ───────────────────────────────────────────────────
177
248
  'detail.header.close': 'Back to board',
178
249
  'detail.header.listenOn': 'Listening',
179
250
  'detail.header.listenOff': 'Listen',
180
251
  'detail.header.listenHint': 'New events append live and get highlighted while on',
252
+ 'detail.header.refresh': 'Refresh',
253
+ 'detail.header.refreshing': 'Refreshing…',
254
+ 'detail.header.refreshHint': 'Pull the newest timeline window',
255
+ 'detail.header.copyIdTitle': 'Click to copy the session ID',
256
+ 'detail.header.copied': 'Copied',
181
257
  'detail.header.untitled': '(untitled)',
182
258
  'detail.header.unknownProject': 'Unknown project',
183
259
  'detail.header.observedDisclaimer':
@@ -204,6 +280,9 @@ export const en = {
204
280
  'detail.kind.other': 'Event',
205
281
  'detail.gap.label':
206
282
  'Gap: about {n} events may be uncaptured (256-slot queue cap or not persisted)',
283
+ 'detail.filter.conversation': 'Conversation only',
284
+ 'detail.filter.all': 'All events',
285
+ 'detail.filter.hiddenNotice': '{n} protocol events hidden',
207
286
  'detail.timeline.loadMore': 'Load older history',
208
287
  'detail.timeline.loadingMore': 'Loading…',
209
288
  'detail.timeline.noMore': 'Start of the timeline',
@@ -213,6 +292,7 @@ export const en = {
213
292
  'detail.timeline.seq': 'seq {n}',
214
293
  'detail.timeline.hiddenNotice': '{n} earlier entries collapsed to stay smooth',
215
294
  'detail.timeline.showAll': 'Show all',
295
+ 'detail.timeline.chunkRun': '{n} streaming chunks',
216
296
  'detail.states.loadingTitle': 'Loading the timeline…',
217
297
  'detail.states.emptyTitle': 'No events yet',
218
298
  'detail.states.emptyHint': 'This session has no normalized events to show yet.',
@@ -233,6 +313,9 @@ export const en = {
233
313
  'detail.actions.analyze': 'AI analysis',
234
314
  'detail.actions.analyzeDisabledHint':
235
315
  'Enable "AI bypass analysis" in Settings to use this',
316
+ 'detail.tools.title': 'Lineage & search',
317
+ 'detail.tools.show': 'Show',
318
+ 'detail.tools.hide': 'Hide',
236
319
 
237
320
  // ── dsh deep-query tools ────────────────────────────────────────────────
238
321
  'dshtools.lineage.title': 'Session lineage',
@@ -287,6 +370,8 @@ export const en = {
287
370
  'project.lastActive': 'Last active {time}',
288
371
  'project.liveChip': 'Live',
289
372
  'project.untitled': '(untitled)',
373
+ 'project.showAllSessions': 'Show all {n} sessions',
374
+ 'project.showLessSessions': 'Show first {n} only',
290
375
  'project.empty.title': 'No project correlation yet',
291
376
  'project.empty.hint':
292
377
  'No cross-agent project activity within the time window; projects appear here once an agent works inside a project directory.',
@@ -357,7 +442,7 @@ export const en = {
357
442
  'command.noSessions': 'No observed sessions yet',
358
443
  'command.unknownProject': 'Unknown project',
359
444
  'command.untitled': '(untitled)',
360
- 'command.truncated': '{n} more active sessions not listed',
445
+ 'command.truncated': '{n} more sessions not listed',
361
446
  'command.boardHint': 'Open the "Sidecar" tab in the conversation view for the full board',
362
447
  'command.unreachable': 'sidecar is not connected',
363
448
  'command.unreachableHint':
@@ -373,7 +458,9 @@ export const en = {
373
458
  'command.time.hoursAgo': '{n} h ago',
374
459
  'command.time.daysAgo': '{n} d ago',
375
460
 
376
- // ── better-sidebar mini tab (T6.3) ─────────────────────────────────────
461
+ // ── sidebar navigation (plain DOM + optional better-sidebar) ───────────
462
+ 'sidebar.centerEntryLabel': 'Agent Center',
463
+ 'sidebar.centerEntryAria': 'Open Agent Center',
377
464
  'sidebar.tabTitle': 'Sidecar',
378
465
  'sidebar.countsRow': '{working} working · {waiting} waiting',
379
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
- * BRIDGE PATH (for the wiring task): each dictionary is a flat
18
- * `Record<string, string>` — exactly the shape the ecosystem locale
19
- * service's untyped overload `ctx.locale.register(ns, locale, dict)`
20
- * accepts so when the runtime composition provides `ctx.locale`, the
21
- * wiring half can feed `dictionaries.zh` / `dictionaries.en` straight into
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 = 'zh' | 'en'
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
+ }