@modesto_ma/dsh-channels 0.1.0-beta.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.
@@ -0,0 +1,862 @@
1
+ /**
2
+ * 设置 →「飞书渠道」页面:数据加载、5 秒状态轮询、全局 draft 与顶部状态头 /
3
+ * tab 导航的外壳。
4
+ *
5
+ * 所有 Host 交互(panel / status / catalog / config / credentials / cleanup /
6
+ * access / pick-directory)都集中在这里;tab 组件只接收渲染所需的数据与
7
+ * 回调。页面不 import 任何 DSH 客户端包,只依赖 react 与 `settings.section`
8
+ * 槽位。
9
+ *
10
+ * @module dsh-channels/client/panel
11
+ */
12
+
13
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
14
+ import type { KeyboardEvent } from 'react'
15
+ import { PANEL_COPY, PANEL_TABS, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
16
+ import type {
17
+ AccessWire,
18
+ CatalogWire,
19
+ ChannelStatusWire,
20
+ CleanupRunWire,
21
+ ConfigValue,
22
+ CredentialField,
23
+ EditableKey,
24
+ EditableValues,
25
+ FieldIssueWire,
26
+ PanelTabId,
27
+ PanelWire,
28
+ } from '../../settings/contract.ts'
29
+ import {
30
+ addAccessGroup,
31
+ addAccessUser,
32
+ clearConfig,
33
+ clearCredential,
34
+ fetchCatalog,
35
+ fetchPanel,
36
+ fetchStatus,
37
+ pickDirectory as requestDirectoryPicker,
38
+ reconnect,
39
+ removeAccessGroup,
40
+ removeAccessUser,
41
+ resolveAccessRequest,
42
+ runCleanup,
43
+ saveConfig,
44
+ saveCredentials,
45
+ } from '../api.ts'
46
+ import { applyCatalogFields, catalogFromFields } from '../catalog.ts'
47
+ import { AccessTab } from './access-tab.tsx'
48
+ import { AdvancedTab } from './advanced-tab.tsx'
49
+ import { ConnectionTab } from './connection-tab.tsx'
50
+ import { ModelTab } from './model-tab.tsx'
51
+ import { SessionsTab } from './sessions-tab.tsx'
52
+ import {
53
+ fieldLabel,
54
+ formatBytes,
55
+ messageOf,
56
+ NoticeBar,
57
+ phaseBadge,
58
+ phaseSummary,
59
+ validateDraft,
60
+ } from './shared.tsx'
61
+ import type {
62
+ BusyAction,
63
+ CredentialsView,
64
+ FormView,
65
+ LoadState,
66
+ Notice,
67
+ } from './shared.tsx'
68
+ import { WorkspaceTab } from './workspace-tab.tsx'
69
+
70
+ /** Owner props handed to a `settings.section` contribution (unused beyond parity). */
71
+ export interface FeishuChannelsSectionProps {
72
+ close?: () => void
73
+ }
74
+
75
+ /** Page shell: one panel payload, one status poll, one global config draft. */
76
+ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.Element | null {
77
+ const [load, setLoad] = useState<LoadState>({ phase: 'loading' })
78
+ const [activeTab, setActiveTab] = useState<PanelTabId>('connection')
79
+ const [draft, setDraft] = useState<EditableValues>({})
80
+ const [notice, setNotice] = useState<Notice | null>(null)
81
+ const [busy, setBusy] = useState<BusyAction | null>(null)
82
+ const [status, setStatus] = useState<ChannelStatusWire | null>(null)
83
+ const [pollError, setPollError] = useState<string | null>(null)
84
+ const [appIdInput, setAppIdInput] = useState('')
85
+ const [appSecretInput, setAppSecretInput] = useState('')
86
+ const [catalog, setCatalog] = useState<CatalogWire | null>(null)
87
+ const [catalogBusy, setCatalogBusy] = useState(false)
88
+ const [cleanupResult, setCleanupResult] = useState<CleanupRunWire | null>(null)
89
+ const [cleanupError, setCleanupError] = useState<string | null>(null)
90
+ const [accessNotice, setAccessNotice] = useState<string | null>(null)
91
+ const [accessError, setAccessError] = useState<string | null>(null)
92
+ const catalogTicket = useRef(0)
93
+ /** Latest ready panel, read from the poll loop without restarting it. */
94
+ const panelRef = useRef<PanelWire | null>(null)
95
+ const panel = load.phase === 'ready' ? load.panel : null
96
+ /** Host-side lifecycle busy: connecting/restarting blocks every panel write. */
97
+ const hostBusy = (status ?? panel?.status)?.busy === true
98
+ useEffect(() => {
99
+ panelRef.current = panel
100
+ }, [panel])
101
+
102
+ const loadPanel = useCallback(async (): Promise<void> => {
103
+ setLoad({ phase: 'loading' })
104
+ setNotice(null)
105
+ setCleanupResult(null)
106
+ setCleanupError(null)
107
+ setAccessNotice(null)
108
+ setAccessError(null)
109
+ try {
110
+ const next = await fetchPanel()
111
+ setLoad({ phase: 'ready', panel: next })
112
+ setStatus(next.status)
113
+ setDraft({})
114
+ setAppIdInput('')
115
+ setAppSecretInput('')
116
+ } catch (error) {
117
+ setLoad({ phase: 'error', message: messageOf(error) })
118
+ }
119
+ }, [])
120
+
121
+ useEffect(() => {
122
+ void loadPanel()
123
+ }, [loadPanel])
124
+
125
+ // The Host panel payload already carries options resolved for the effective
126
+ // route; re-seed the local catalog whenever a fresh panel arrives (load/save).
127
+ useEffect(() => {
128
+ if (panel === null) {
129
+ setCatalog(null)
130
+ return
131
+ }
132
+ setCatalog(catalogFromFields(panel.config.fields))
133
+ setCatalogBusy(false)
134
+ }, [panel])
135
+
136
+ useEffect(() => {
137
+ if (load.phase !== 'ready') return undefined
138
+ let cancelled = false
139
+ const tick = async (): Promise<void> => {
140
+ try {
141
+ const previousPhase = panelRef.current?.status.phase
142
+ const next = await fetchStatus()
143
+ if (cancelled) return
144
+ setStatus(next)
145
+ setPollError(null)
146
+ // A phase transition changes what the panel payload can offer: the
147
+ // storage domain opens/closes with the connection, so the access
148
+ // whitelists and the cleanup preview go stale. Re-read the panel but
149
+ // never touch the dirty draft.
150
+ if (previousPhase !== undefined && previousPhase !== next.phase) {
151
+ try {
152
+ const refreshed = await fetchPanel()
153
+ if (!cancelled) {
154
+ setLoad({ phase: 'ready', panel: refreshed })
155
+ setStatus(refreshed.status)
156
+ }
157
+ } catch {
158
+ // Keep the status-only update; the next manual refresh retries.
159
+ }
160
+ }
161
+ } catch (error) {
162
+ if (!cancelled) setPollError(messageOf(error))
163
+ }
164
+ }
165
+ const timer = window.setInterval(() => { void tick() }, 5_000)
166
+ return () => {
167
+ cancelled = true
168
+ window.clearInterval(timer)
169
+ }
170
+ }, [load.phase])
171
+
172
+ /** Fetch the selector catalog for a draft route (latest request wins). */
173
+ const refreshCatalog = useCallback(async (provider: string, model: string): Promise<void> => {
174
+ catalogTicket.current += 1
175
+ const ticket = catalogTicket.current
176
+ setCatalogBusy(true)
177
+ try {
178
+ const next = await fetchCatalog(provider, model)
179
+ if (catalogTicket.current === ticket) setCatalog(next)
180
+ } catch {
181
+ if (catalogTicket.current !== ticket) return
182
+ // Never keep another route's options: degrade the dependent fields to the
183
+ // free-text controls instead of offering stale values.
184
+ setCatalog(previous => previous === null ? null : {
185
+ provider: previous.provider,
186
+ model: { selectable: false, options: [] },
187
+ reasoningEffort: { selectable: false, options: [] },
188
+ agentPreset: previous.agentPreset,
189
+ })
190
+ setNotice({ kind: 'error', text: PANEL_COPY.catalogFailed })
191
+ } finally {
192
+ if (catalogTicket.current === ticket) setCatalogBusy(false)
193
+ }
194
+ }, [])
195
+
196
+ /** Current form values, used to keep unlisted configured values visible. */
197
+ const currentValues = useMemo(() => {
198
+ if (panel === null) return {}
199
+ const valueOf = (key: EditableKey): string | undefined => {
200
+ const raw = draft[key] ?? panel.config.effective[key] ?? panel.config.base[key]
201
+ return typeof raw === 'string' && raw !== '' ? raw : undefined
202
+ }
203
+ return {
204
+ provider: valueOf('provider'),
205
+ model: valueOf('model'),
206
+ reasoningEffort: valueOf('reasoningEffort'),
207
+ agentPreset: valueOf('agentPreset'),
208
+ }
209
+ }, [draft, panel])
210
+
211
+ const renderedFields = useMemo(
212
+ () => applyCatalogFields(panel?.config.fields ?? [], catalog, currentValues),
213
+ [panel, catalog, currentValues],
214
+ )
215
+
216
+ /** Whether one selector currently renders as a dropdown for this form. */
217
+ const isSelectField = useCallback(
218
+ (key: EditableKey): boolean => renderedFields.some(field => field.key === key && field.kind === 'select'),
219
+ [renderedFields],
220
+ )
221
+
222
+ /** Draft one field, cascading provider → model → effort like the Host catalog. */
223
+ const updateDraft = useCallback((key: EditableKey, value: ConfigValue) => {
224
+ if (panel === null) return
225
+ const providerNow = String(
226
+ draft.provider ?? panel.config.effective.provider ?? panel.config.base.provider ?? '',
227
+ )
228
+ setNotice(null)
229
+ if (key === 'provider') {
230
+ // Changing the provider invalidates the model/effort below it. When the
231
+ // selected provider is the configured one, keep showing its configured
232
+ // model; otherwise fall back to the inherit choice.
233
+ const nextProvider = String(value ?? '')
234
+ const configuredProvider = String(
235
+ panel.config.effective.provider ?? panel.config.base.provider ?? '',
236
+ )
237
+ const keepModel = nextProvider === configuredProvider
238
+ ? String(panel.config.effective.model ?? panel.config.base.model ?? '')
239
+ : ''
240
+ setDraft(previous => ({ ...previous, provider: value, model: '', reasoningEffort: '' }))
241
+ if (catalog !== null && isSelectField('provider')) void refreshCatalog(nextProvider, keepModel)
242
+ return
243
+ }
244
+ if (key === 'model') {
245
+ setDraft(previous => ({ ...previous, model: value, reasoningEffort: '' }))
246
+ if (catalog !== null && isSelectField('model')) void refreshCatalog(providerNow, String(value ?? ''))
247
+ return
248
+ }
249
+ setDraft(previous => ({ ...previous, [key]: value }))
250
+ }, [catalog, draft.provider, isSelectField, panel, refreshCatalog])
251
+
252
+ const refreshStatus = useCallback(async (): Promise<void> => {
253
+ try {
254
+ const next = await fetchStatus()
255
+ setStatus(next)
256
+ setPollError(null)
257
+ } catch (error) {
258
+ setPollError(messageOf(error))
259
+ }
260
+ }, [])
261
+
262
+ /**
263
+ * Manual refresh: reload the whole panel payload so access lists, the cleanup
264
+ * preview and `restartPendingKeys` catch up with the Host. The dirty draft is
265
+ * deliberately preserved — refreshing never discards user input.
266
+ */
267
+ const onRefresh = useCallback(async (): Promise<void> => {
268
+ try {
269
+ const next = await fetchPanel()
270
+ setLoad({ phase: 'ready', panel: next })
271
+ setStatus(next.status)
272
+ setPollError(null)
273
+ setAccessNotice(null)
274
+ setAccessError(null)
275
+ } catch (error) {
276
+ setPollError(messageOf(error))
277
+ }
278
+ }, [])
279
+
280
+ /** Submit every dirty field at once; the Host persists and re-validates. */
281
+ const onSave = useCallback(async (): Promise<void> => {
282
+ if (panel === null) return
283
+ const { values, issues } = validateDraft(panel, draft)
284
+ if (issues.length > 0) {
285
+ // Name the offending fields: with one draft shared by every tab, the
286
+ // user may be looking at a different tab than the invalid field.
287
+ const detail = issues
288
+ .map(issue => `${fieldLabel(panel.config.fields, issue.key as EditableKey)} ${issue.message}`)
289
+ .join(';')
290
+ setNotice({ kind: 'error', text: `${PANEL_COPY.saveInvalid}:${detail}`, issues })
291
+ return
292
+ }
293
+ if (Object.keys(values).length === 0) return
294
+ setBusy('save')
295
+ setNotice(null)
296
+ try {
297
+ const result = await saveConfig(values)
298
+ if (!result.ok) {
299
+ setNotice({ kind: 'error', text: result.error, issues: result.issues })
300
+ return
301
+ }
302
+ setLoad({ phase: 'ready', panel: result.panel })
303
+ setStatus(result.panel.status)
304
+ setCleanupResult(null)
305
+ setCleanupError(null)
306
+ // Clear only the submitted keys: edits typed while the request was in
307
+ // flight stay in the draft instead of being silently discarded.
308
+ setDraft(previous => {
309
+ const next = { ...previous }
310
+ for (const key of Object.keys(values) as EditableKey[]) delete next[key]
311
+ return next
312
+ })
313
+ setNotice({ kind: 'ok', text: PANEL_COPY.saved })
314
+ } catch (error) {
315
+ setNotice({ kind: 'error', text: messageOf(error) })
316
+ } finally {
317
+ setBusy(null)
318
+ }
319
+ }, [draft, panel])
320
+
321
+ /** Drop the draft keys owned by one tab (nothing is written). */
322
+ const discardTab = useCallback((tab: PanelTabId): void => {
323
+ if (panel === null) return
324
+ const groups = new Set<string>(TAB_FIELD_GROUPS[tab])
325
+ const keys = new Set(
326
+ panel.config.fields.filter(field => groups.has(field.group)).map(field => field.key),
327
+ )
328
+ setDraft((previous) => {
329
+ const next = { ...previous }
330
+ for (const key of keys) delete next[key]
331
+ return next
332
+ })
333
+ setNotice(null)
334
+ if (tab === 'model') {
335
+ catalogTicket.current += 1
336
+ setCatalog(catalogFromFields(panel.config.fields))
337
+ setCatalogBusy(false)
338
+ }
339
+ }, [panel])
340
+
341
+ /**
342
+ * Open the host's native directory chooser (workspace root「选择目录…」) and
343
+ * stage the picked absolute path as a draft value. Nothing is persisted until
344
+ *「保存更改」; a canceled dialog is a no-op, not an error.
345
+ */
346
+ const onPickDirectory = useCallback(async (key: EditableKey): Promise<void> => {
347
+ setNotice(null)
348
+ try {
349
+ const result = await requestDirectoryPicker()
350
+ if (!result.ok) {
351
+ setNotice({ kind: 'error', text: result.error })
352
+ return
353
+ }
354
+ if ('canceled' in result) return
355
+ setDraft(previous => ({ ...previous, [key]: result.path }))
356
+ setNotice({ kind: 'ok', text: `${PANEL_COPY.directoryPicked}:${result.path}` })
357
+ } catch (error) {
358
+ setNotice({ kind: 'error', text: messageOf(error) })
359
+ }
360
+ }, [])
361
+
362
+ /** Drop every Web override and return the refreshed panel. */
363
+ const onClear = useCallback(async (): Promise<void> => {
364
+ setBusy('clear')
365
+ setNotice(null)
366
+ try {
367
+ const result = await clearConfig()
368
+ if (!result.ok) {
369
+ setNotice({ kind: 'error', text: result.error })
370
+ return
371
+ }
372
+ setLoad({ phase: 'ready', panel: result.panel })
373
+ setStatus(result.panel.status)
374
+ setDraft({})
375
+ setCleanupResult(null)
376
+ setCleanupError(null)
377
+ setNotice({ kind: 'ok', text: PANEL_COPY.cleared })
378
+ } catch (error) {
379
+ setNotice({ kind: 'error', text: messageOf(error) })
380
+ } finally {
381
+ setBusy(null)
382
+ }
383
+ }, [])
384
+
385
+ const running = (status ?? panel?.status)?.running === true
386
+
387
+ /**
388
+ * The single connect/reconnect action: open a stopped channel or restart a
389
+ * running one. Only a restart interrupts live replies, so only it confirms.
390
+ */
391
+ const onReconnect = useCallback(async (): Promise<void> => {
392
+ if (running) {
393
+ const confirmed = typeof window.confirm === 'function'
394
+ ? window.confirm(PANEL_COPY.restartWarning)
395
+ : true
396
+ if (!confirmed) return
397
+ }
398
+ setBusy('reconnect')
399
+ setNotice(null)
400
+ try {
401
+ const result = await reconnect()
402
+ if (!result.ok) {
403
+ setNotice({ kind: 'error', text: result.error })
404
+ // Incomplete credentials: send the user back to the onboarding form.
405
+ if (result.unconfigured === true) setActiveTab('connection')
406
+ await refreshStatus()
407
+ return
408
+ }
409
+ setLoad({ phase: 'ready', panel: result.panel })
410
+ setStatus(result.panel.status)
411
+ setNotice({ kind: 'ok', text: running ? PANEL_COPY.reconnectDone : PANEL_COPY.connectDone })
412
+ } catch (error) {
413
+ setNotice({ kind: 'error', text: messageOf(error) })
414
+ await refreshStatus()
415
+ } finally {
416
+ setBusy(null)
417
+ }
418
+ }, [refreshStatus, running])
419
+
420
+ /**
421
+ * Delete stale workspaces after an explicit confirmation. The Host refreshes
422
+ * the panel in the same response, so the preview counts update immediately;
423
+ * partial failures stay visible in the card instead of being swallowed.
424
+ */
425
+ const onRunCleanup = useCallback(async (): Promise<void> => {
426
+ if (panel === null) return
427
+ const { cleanup } = panel
428
+ const confirmed = typeof window.confirm === 'function'
429
+ ? window.confirm(
430
+ `${PANEL_COPY.cleanupConfirm}\n${PANEL_COPY.cleanupCandidates}:${cleanup.candidates} · ${PANEL_COPY.cleanupCandidateBytes}:${formatBytes(cleanup.candidateBytes)}`,
431
+ )
432
+ : true
433
+ if (!confirmed) return
434
+ setBusy('cleanup')
435
+ setNotice(null)
436
+ setCleanupResult(null)
437
+ setCleanupError(null)
438
+ try {
439
+ const response = await runCleanup()
440
+ if (!response.ok) {
441
+ setCleanupError(response.error)
442
+ return
443
+ }
444
+ setLoad({ phase: 'ready', panel: response.panel })
445
+ setStatus(response.panel.status)
446
+ setCleanupResult(response.result)
447
+ } catch (error) {
448
+ setCleanupError(messageOf(error))
449
+ } finally {
450
+ setBusy(null)
451
+ }
452
+ }, [panel])
453
+
454
+ /** Replace the embedded access snapshot after a whitelist mutation. */
455
+ const applyAccess = useCallback((access: AccessWire): void => {
456
+ setLoad(previous => previous.phase === 'ready'
457
+ ? { phase: 'ready', panel: { ...previous.panel, access } }
458
+ : previous)
459
+ }, [])
460
+
461
+ const onAddAccessGroup = useCallback(async (chatId: string, name: string): Promise<boolean> => {
462
+ setBusy('access')
463
+ setAccessError(null)
464
+ setAccessNotice(null)
465
+ try {
466
+ const result = await addAccessGroup(chatId, name)
467
+ if (!result.ok) {
468
+ setAccessError(result.error)
469
+ return false
470
+ }
471
+ applyAccess(result.access)
472
+ setAccessNotice(PANEL_COPY.accessGroupAdded)
473
+ return true
474
+ } catch (error) {
475
+ setAccessError(messageOf(error))
476
+ return false
477
+ } finally {
478
+ setBusy(null)
479
+ }
480
+ }, [applyAccess])
481
+
482
+ const onRemoveAccessGroup = useCallback(async (chatId: string): Promise<void> => {
483
+ setBusy('access')
484
+ setAccessError(null)
485
+ setAccessNotice(null)
486
+ try {
487
+ const result = await removeAccessGroup(chatId)
488
+ if (!result.ok) {
489
+ setAccessError(result.error)
490
+ return
491
+ }
492
+ applyAccess(result.access)
493
+ setAccessNotice(PANEL_COPY.accessGroupRemoved)
494
+ } catch (error) {
495
+ setAccessError(messageOf(error))
496
+ } finally {
497
+ setBusy(null)
498
+ }
499
+ }, [applyAccess])
500
+
501
+ const onAddAccessUser = useCallback(async (openId: string, name: string): Promise<boolean> => {
502
+ setBusy('access')
503
+ setAccessError(null)
504
+ setAccessNotice(null)
505
+ try {
506
+ const result = await addAccessUser(openId, name)
507
+ if (!result.ok) {
508
+ setAccessError(result.error)
509
+ return false
510
+ }
511
+ applyAccess(result.access)
512
+ setAccessNotice(PANEL_COPY.accessUserAdded)
513
+ return true
514
+ } catch (error) {
515
+ setAccessError(messageOf(error))
516
+ return false
517
+ } finally {
518
+ setBusy(null)
519
+ }
520
+ }, [applyAccess])
521
+
522
+ const onRemoveAccessUser = useCallback(async (openId: string): Promise<void> => {
523
+ setBusy('access')
524
+ setAccessError(null)
525
+ setAccessNotice(null)
526
+ try {
527
+ const result = await removeAccessUser(openId)
528
+ if (!result.ok) {
529
+ setAccessError(result.error)
530
+ return
531
+ }
532
+ applyAccess(result.access)
533
+ setAccessNotice(PANEL_COPY.accessUserRemoved)
534
+ } catch (error) {
535
+ setAccessError(messageOf(error))
536
+ } finally {
537
+ setBusy(null)
538
+ }
539
+ }, [applyAccess])
540
+
541
+ const onResolveAccessRequest = useCallback(async (token: string, decision: 'allow' | 'deny'): Promise<void> => {
542
+ setBusy('access')
543
+ setAccessError(null)
544
+ setAccessNotice(null)
545
+ try {
546
+ const result = await resolveAccessRequest(token, decision)
547
+ if (!result.ok) {
548
+ setAccessError(result.error)
549
+ return
550
+ }
551
+ applyAccess(result.access)
552
+ setAccessNotice(decision === 'allow' ? PANEL_COPY.accessRequestAllowed : PANEL_COPY.accessRequestDenied)
553
+ } catch (error) {
554
+ setAccessError(messageOf(error))
555
+ } finally {
556
+ setBusy(null)
557
+ }
558
+ }, [applyAccess])
559
+
560
+ /** Save provided credential values; the Host reconnects automatically when ready. */
561
+ const onSaveCredentials = useCallback(async (): Promise<void> => {
562
+ const values: { appId?: string; appSecret?: string } = {}
563
+ if (appIdInput.trim() !== '') values.appId = appIdInput
564
+ if (appSecretInput.trim() !== '') values.appSecret = appSecretInput
565
+ if (values.appId === undefined && values.appSecret === undefined) return
566
+ setBusy('credentials')
567
+ setNotice(null)
568
+ try {
569
+ const result = await saveCredentials(values)
570
+ if (!result.ok) {
571
+ setNotice({ kind: 'error', text: result.error, issues: result.issues })
572
+ return
573
+ }
574
+ setLoad({ phase: 'ready', panel: result.panel })
575
+ setStatus(result.panel.status)
576
+ setAppIdInput('')
577
+ setAppSecretInput('')
578
+ const base = result.reconnect === 'pending'
579
+ ? PANEL_COPY.credentialSavedPending
580
+ : result.reconnect === 'auto'
581
+ ? PANEL_COPY.credentialSavedReconnected
582
+ : result.reconnect === 'unconfigured'
583
+ ? PANEL_COPY.credentialSavedUnconfigured
584
+ : result.reconnect === 'failed'
585
+ ? PANEL_COPY.credentialAutoConnectFailed
586
+ : PANEL_COPY.credentialSaved
587
+ setNotice({
588
+ kind: result.reconnect === 'failed' || result.autoReconnectError !== undefined ? 'error' : 'ok',
589
+ text: result.autoReconnectError === undefined
590
+ ? base
591
+ : `${base}:${result.autoReconnectError}`,
592
+ })
593
+ } catch (error) {
594
+ setNotice({ kind: 'error', text: messageOf(error) })
595
+ } finally {
596
+ setBusy(null)
597
+ }
598
+ }, [appIdInput, appSecretInput])
599
+
600
+ const onClearCredential = useCallback(async (field: CredentialField): Promise<void> => {
601
+ const confirmed = typeof window.confirm === 'function'
602
+ ? window.confirm(PANEL_COPY.credentialRemoveConfirm)
603
+ : true
604
+ if (!confirmed) return
605
+ setBusy('credentials-clear')
606
+ setNotice(null)
607
+ try {
608
+ const result = await clearCredential(field)
609
+ if (!result.ok) {
610
+ setNotice({ kind: 'error', text: result.error, issues: result.issues })
611
+ return
612
+ }
613
+ setLoad({ phase: 'ready', panel: result.panel })
614
+ setStatus(result.panel.status)
615
+ setNotice({
616
+ kind: 'ok',
617
+ text: result.reconnect === 'pending' ? PANEL_COPY.credentialRemovedPending : PANEL_COPY.credentialRemoved,
618
+ })
619
+ } catch (error) {
620
+ setNotice({ kind: 'error', text: messageOf(error) })
621
+ } finally {
622
+ setBusy(null)
623
+ }
624
+ }, [])
625
+
626
+ const issues = useMemo<FieldIssueWire[]>(
627
+ () => (notice?.kind === 'error' ? notice.issues ?? [] : []),
628
+ [notice],
629
+ )
630
+ const dirtyCount = Object.keys(draft).length
631
+
632
+ /** Which tabs currently own a dirty key (the dot is per tab, not global). */
633
+ const dirtyTabs = useMemo<ReadonlySet<PanelTabId>>(() => {
634
+ const tabs = new Set<PanelTabId>()
635
+ if (panel === null || dirtyCount === 0) return tabs
636
+ const groupOf = new Map(panel.config.fields.map(field => [field.key, field.group]))
637
+ for (const key of Object.keys(draft)) {
638
+ const group = groupOf.get(key as EditableKey)
639
+ if (group === undefined) continue
640
+ for (const tab of PANEL_TABS) {
641
+ if (TAB_FIELD_GROUPS[tab.id].includes(group)) tabs.add(tab.id)
642
+ }
643
+ }
644
+ return tabs
645
+ }, [dirtyCount, draft, panel])
646
+
647
+ const form = useMemo<FormView | null>(() => {
648
+ if (panel === null) return null
649
+ return {
650
+ panel,
651
+ fields: renderedFields,
652
+ draft,
653
+ issues,
654
+ busy,
655
+ locked: hostBusy,
656
+ dirtyCount,
657
+ onChange: updateDraft,
658
+ onSave: () => { void onSave() },
659
+ onDiscardTab: discardTab,
660
+ onPickDirectory,
661
+ }
662
+ }, [busy, discardTab, dirtyCount, draft, hostBusy, issues, onPickDirectory, onSave, panel, renderedFields, updateDraft])
663
+
664
+ const credentials = useMemo<CredentialsView>(() => ({
665
+ appId: appIdInput,
666
+ appSecret: appSecretInput,
667
+ issues,
668
+ busy,
669
+ locked: hostBusy,
670
+ onAppIdChange: setAppIdInput,
671
+ onAppSecretChange: setAppSecretInput,
672
+ onSave: () => { void onSaveCredentials() },
673
+ onClear: (field: CredentialField) => { void onClearCredential(field) },
674
+ }), [appIdInput, appSecretInput, busy, hostBusy, issues, onClearCredential, onSaveCredentials])
675
+
676
+ if (load.phase === 'loading') {
677
+ return (
678
+ <div className="dsh-channels-cfg">
679
+ <div className="dsh-channels-cfg__loading">{PANEL_COPY.loading}</div>
680
+ </div>
681
+ )
682
+ }
683
+ if (load.phase === 'error') {
684
+ return (
685
+ <div className="dsh-channels-cfg">
686
+ <div className="dsh-channels-cfg__error">
687
+ <strong>{PANEL_COPY.loadFailed}</strong>
688
+ <span>{load.message}</span>
689
+ <div>
690
+ <button type="button" className="dsh-channels-cfg__btn" onClick={() => { void loadPanel() }}>
691
+ {PANEL_COPY.retry}
692
+ </button>
693
+ </div>
694
+ </div>
695
+ </div>
696
+ )
697
+ }
698
+ if (form === null) return null
699
+ const current = status ?? load.panel.status
700
+
701
+ return (
702
+ <div className="dsh-channels-cfg">
703
+ <PanelHeader
704
+ status={current}
705
+ pollError={pollError}
706
+ busy={busy}
707
+ locked={hostBusy}
708
+ onRefresh={() => { void onRefresh() }}
709
+ />
710
+ <TabNav activeTab={activeTab} dirtyTabs={dirtyTabs} onChange={setActiveTab} />
711
+ {notice === null ? null : <NoticeBar notice={notice} />}
712
+ <div
713
+ role="tabpanel"
714
+ id={`dsh-channels-tabpanel-${activeTab}`}
715
+ aria-labelledby={`dsh-channels-tab-${activeTab}`}
716
+ >
717
+ {activeTab === 'connection'
718
+ ? (
719
+ <ConnectionTab
720
+ panel={form.panel}
721
+ status={current}
722
+ busy={busy}
723
+ credentials={credentials}
724
+ onReconnect={() => { void onReconnect() }}
725
+ />
726
+ )
727
+ : null}
728
+ {activeTab === 'sessions' ? <SessionsTab form={form} /> : null}
729
+ {activeTab === 'model' ? <ModelTab form={form} catalogBusy={catalogBusy} /> : null}
730
+ {activeTab === 'workspace'
731
+ ? (
732
+ <WorkspaceTab
733
+ form={form}
734
+ cleanup={form.panel.cleanup}
735
+ cleanupResult={cleanupResult}
736
+ cleanupError={cleanupError}
737
+ onRunCleanup={() => { void onRunCleanup() }}
738
+ />
739
+ )
740
+ : null}
741
+ {activeTab === 'access'
742
+ ? (
743
+ <AccessTab
744
+ form={form}
745
+ accessNotice={accessNotice}
746
+ accessError={accessError}
747
+ onAddGroup={onAddAccessGroup}
748
+ onRemoveGroup={chatId => { void onRemoveAccessGroup(chatId) }}
749
+ onAddUser={onAddAccessUser}
750
+ onRemoveUser={openId => { void onRemoveAccessUser(openId) }}
751
+ onResolveRequest={(token, decision) => { void onResolveAccessRequest(token, decision) }}
752
+ />
753
+ )
754
+ : null}
755
+ {activeTab === 'advanced'
756
+ ? <AdvancedTab form={form} onClear={() => { void onClear() }} />
757
+ : null}
758
+ </div>
759
+ </div>
760
+ )
761
+ }
762
+
763
+ /** Page header: title + phase badge + refresh, visible on every tab. */
764
+ function PanelHeader(props: {
765
+ status: ChannelStatusWire
766
+ pollError: string | null
767
+ busy: BusyAction | null
768
+ locked: boolean
769
+ onRefresh: () => void
770
+ }): JSX.Element {
771
+ const { status, pollError, busy, locked, onRefresh } = props
772
+ const badge = phaseBadge(status.phase)
773
+ return (
774
+ <header className="dsh-channels-cfg__panelHead" title={PANEL_COPY.description}>
775
+ <div className="dsh-channels-cfg__headRow">
776
+ <h2 className="dsh-channels-cfg__title">{PANEL_COPY.title}</h2>
777
+ <span className={`dsh-channels-cfg__badge dsh-channels-cfg__badge--${badge.tone}`}>
778
+ <span className="dsh-channels-cfg__dot" />
779
+ {badge.text}
780
+ </span>
781
+ <span className="dsh-channels-cfg__spacer" />
782
+ <button
783
+ type="button"
784
+ className="dsh-channels-cfg__btn"
785
+ onClick={onRefresh}
786
+ disabled={busy !== null || locked}
787
+ >
788
+ {PANEL_COPY.refresh}
789
+ </button>
790
+ </div>
791
+ <p className="dsh-channels-cfg__summary">{phaseSummary(status.phase)}</p>
792
+ {pollError === null
793
+ ? null
794
+ : (
795
+ <span className="dsh-channels-cfg__fieldError" role="status" aria-live="polite">
796
+ {PANEL_COPY.pollFailed}:{pollError}
797
+ </span>
798
+ )}
799
+ </header>
800
+ )
801
+ }
802
+
803
+ /** Top-level tab navigation; the dirty dot keeps unsaved work visible per tab. */
804
+ function TabNav(props: {
805
+ activeTab: PanelTabId
806
+ dirtyTabs: ReadonlySet<PanelTabId>
807
+ onChange: (tab: PanelTabId) => void
808
+ }): JSX.Element {
809
+ const { activeTab, dirtyTabs, onChange } = props
810
+ const refs = useRef<Array<HTMLButtonElement | null>>([])
811
+
812
+ /** Roving-focus keyboard navigation between tabs (WAI-ARIA tabs pattern). */
813
+ const onKeyDown = (index: number, event: KeyboardEvent<HTMLButtonElement>): void => {
814
+ const last = PANEL_TABS.length - 1
815
+ let next: number
816
+ switch (event.key) {
817
+ case 'ArrowRight': next = index === last ? 0 : index + 1; break
818
+ case 'ArrowLeft': next = index === 0 ? last : index - 1; break
819
+ case 'Home': next = 0; break
820
+ case 'End': next = last; break
821
+ default: return
822
+ }
823
+ event.preventDefault()
824
+ onChange(PANEL_TABS[next]!.id)
825
+ refs.current[next]?.focus()
826
+ }
827
+
828
+ return (
829
+ <nav className="dsh-channels-cfg__tabs" role="tablist" aria-label={PANEL_COPY.title}>
830
+ {PANEL_TABS.map((tab, index) => {
831
+ const selected = tab.id === activeTab
832
+ const dirty = dirtyTabs.has(tab.id)
833
+ return (
834
+ <button
835
+ key={tab.id}
836
+ ref={(node) => { refs.current[index] = node }}
837
+ type="button"
838
+ role="tab"
839
+ id={`dsh-channels-tab-${tab.id}`}
840
+ aria-selected={selected}
841
+ aria-controls={selected ? `dsh-channels-tabpanel-${tab.id}` : undefined}
842
+ tabIndex={selected ? 0 : -1}
843
+ className="dsh-channels-cfg__tab"
844
+ onClick={() => onChange(tab.id)}
845
+ onKeyDown={event => onKeyDown(index, event)}
846
+ >
847
+ {tab.label}
848
+ {dirty
849
+ ? (
850
+ <span
851
+ className="dsh-channels-cfg__tabDot"
852
+ title={PANEL_COPY.dirtyCount}
853
+ aria-label={PANEL_COPY.dirtyCount}
854
+ />
855
+ )
856
+ : null}
857
+ </button>
858
+ )
859
+ })}
860
+ </nav>
861
+ )
862
+ }