@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.
- package/README.md +78 -11
- package/lib/client.js +4708 -2932
- 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 +255 -48
- package/src/client/board/board.module.css +113 -92
- package/src/client/board/logic.ts +99 -21
- package/src/client/board/project-view-logic.ts +27 -34
- package/src/client/board/project-view.module.css +46 -83
- package/src/client/board/project-view.tsx +50 -8
- package/src/client/board/strings.ts +70 -85
- package/src/client/commands.ts +30 -15
- package/src/client/controller.ts +27 -7
- package/src/client/detail/SessionDetail.tsx +234 -37
- package/src/client/detail/detail.module.css +100 -153
- package/src/client/detail/logic.ts +220 -29
- package/src/client/detail/strings.ts +66 -77
- package/src/client/detail-glue.ts +33 -0
- package/src/client/detail-view.module.css +43 -35
- package/src/client/detail-view.tsx +138 -118
- 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 +53 -140
- package/src/client/dsh-tools/strings.ts +42 -77
- package/src/client/index.ts +285 -124
- package/src/client/inject/InjectPanel.tsx +81 -61
- package/src/client/inject/inject.module.css +97 -197
- package/src/client/inject/logic.ts +38 -0
- package/src/client/lifecycle/handoff.ts +83 -0
- package/src/client/locales/en.ts +91 -4
- 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 +200 -125
- package/src/client/mount.tsx +75 -41
- 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 +42 -16
- package/src/client/inject/overlay.module.css +0 -22
package/src/client/locales/en.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
// ──
|
|
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
|
-
*
|
|
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
|
+
}
|