@modesto_ma/dsh-channels 0.5.0-beta.3 → 0.5.0-beta.5

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 (46) hide show
  1. package/README.md +88 -27
  2. package/docs/attachment-pipeline-v2.md +1 -1
  3. package/docs/feishu-onboarding.md +3 -3
  4. package/lib/client.js +1427 -1338
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +7958 -6710
  7. package/lib/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/access/controller.ts +32 -1
  10. package/src/analyze/contract.ts +121 -0
  11. package/src/analyze/launcher.ts +353 -0
  12. package/src/analyze/server.ts +201 -0
  13. package/src/cards/analyze.ts +116 -0
  14. package/src/cards/index.ts +1 -0
  15. package/src/cards/streaming.ts +58 -0
  16. package/src/client/api.ts +15 -0
  17. package/src/client/number-field.ts +97 -0
  18. package/src/client/panel/access-tab.tsx +61 -20
  19. package/src/client/panel/advanced-tab.tsx +59 -68
  20. package/src/client/panel/connection-tab.tsx +147 -164
  21. package/src/client/panel/credentials-card.tsx +124 -87
  22. package/src/client/panel/fields-tab.tsx +27 -0
  23. package/src/client/panel/index.tsx +126 -55
  24. package/src/client/panel/shared.tsx +369 -252
  25. package/src/client/panel/whitelist.tsx +197 -0
  26. package/src/client/panel/wiki-tab.tsx +48 -0
  27. package/src/client/styles.ts +108 -91
  28. package/src/config.ts +30 -4
  29. package/src/domain.ts +55 -0
  30. package/src/feishu/scopes.ts +13 -7
  31. package/src/feishu/tools.ts +3 -1
  32. package/src/health.ts +12 -0
  33. package/src/host/agent.ts +5 -1
  34. package/src/index.ts +2 -2
  35. package/src/interactions/pending.ts +11 -2
  36. package/src/routing/inbound.ts +57 -14
  37. package/src/service.ts +161 -28
  38. package/src/settings/contract.ts +222 -146
  39. package/src/settings/routes.ts +66 -14
  40. package/src/settings/surface.ts +234 -203
  41. package/src/streamer.ts +156 -7
  42. package/src/workspaces.ts +31 -0
  43. package/src/client/panel/access-card.tsx +0 -303
  44. package/src/client/panel/model-tab.tsx +0 -23
  45. package/src/client/panel/sessions-tab.tsx +0 -21
  46. package/src/client/panel/workspace-tab.tsx +0 -24
@@ -1,16 +1,106 @@
1
1
  /**
2
- * 飞书应用凭据编辑器:值经 Host 的 DSH 凭据服务写入本机凭据库,面板只显示
3
- * 「已配置 / 未配置」,两个方向都不回显已存值。保存动作由页面外壳持有
4
- * (凭据保存独立于配置 draft)。
2
+ * 凭据行:值经 Host 的 DSH 凭据服务写入本机凭据库,面板只显示「已配置 /
3
+ * 未配置」,两个方向都不回显已存值。已配置时只显示「更换 / 移除」,点「更换」
4
+ * 才出现输入框。保存动作由页面外壳持有(凭据保存独立于配置 draft)。
5
5
  *
6
6
  * @module dsh-channels/client/panel/credentials-card
7
7
  */
8
8
 
9
+ import { useEffect, useRef, useState } from 'react'
9
10
  import { PANEL_COPY } from '../../settings/contract.ts'
10
- import type { CredentialField, CredentialRefWire, PanelWire } from '../../settings/contract.ts'
11
- import type { CredentialsView } from './shared.tsx'
11
+ import type { CredentialRefWire, FieldIssueWire, PanelWire } from '../../settings/contract.ts'
12
+ import { Pill, Row, Section } from './shared.tsx'
13
+ import type { BusyAction, CredentialsView } from './shared.tsx'
12
14
 
13
- export function CredentialsCard(props: {
15
+ /** One credential slot as a setting row with its own input and actions. */
16
+ export function CredentialRow(props: {
17
+ label: string
18
+ summary?: string
19
+ state: CredentialRefWire
20
+ value: string
21
+ secret: boolean
22
+ placeholder: string
23
+ issue?: FieldIssueWire
24
+ busy: BusyAction | null
25
+ disabled: boolean
26
+ onChange: (value: string) => void
27
+ onSave: () => void
28
+ onClear: () => void
29
+ }): JSX.Element {
30
+ const { label, summary, state, value, secret, placeholder, issue, busy, disabled, onChange, onSave, onClear } = props
31
+ const [editing, setEditing] = useState(false)
32
+ // Close the replace input once a credential save finishes (the shell clears the value).
33
+ const previousBusy = useRef(busy)
34
+ useEffect(() => {
35
+ if (previousBusy.current === 'credentials' && busy === null) setEditing(false)
36
+ previousBusy.current = busy
37
+ }, [busy])
38
+ const showInput = !state.configured || editing || value !== ''
39
+ const source = `${PANEL_COPY.credentialStore} · ${state.ref}${state.source === undefined ? '' : ` · ${state.source}`}`
40
+ return (
41
+ <Row
42
+ label={label}
43
+ tags={(
44
+ <Pill tone={state.configured ? 'ok' : 'muted'}>
45
+ {state.configured ? PANEL_COPY.credentialConfigured : PANEL_COPY.credentialMissing}
46
+ </Pill>
47
+ )}
48
+ summary={summary === undefined ? <span className="dsh-channels-cfg__mono">{source}</span> : summary}
49
+ error={issue?.message ?? (state.writable ? undefined : PANEL_COPY.credentialReadOnly)}
50
+ control={showInput
51
+ ? (
52
+ <div className="dsh-channels-cfg__inputRow">
53
+ <input
54
+ type={secret ? 'password' : 'text'}
55
+ className={`dsh-channels-cfg__input dsh-channels-cfg__input--text${issue === undefined ? '' : ' dsh-channels-cfg__input--invalid'}`}
56
+ value={value}
57
+ placeholder={placeholder}
58
+ autoComplete="new-password"
59
+ aria-label={label}
60
+ disabled={disabled}
61
+ onChange={event => onChange(event.target.value)}
62
+ />
63
+ <button
64
+ type="button"
65
+ className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
66
+ disabled={disabled || value.trim() === ''}
67
+ onClick={onSave}
68
+ >
69
+ {busy === 'credentials' ? PANEL_COPY.savingCredentials : PANEL_COPY.saveCredentials}
70
+ </button>
71
+ {state.configured
72
+ ? (
73
+ <button
74
+ type="button"
75
+ className="dsh-channels-cfg__btn dsh-channels-cfg__btn--ghost"
76
+ disabled={busy !== null}
77
+ onClick={() => {
78
+ setEditing(false)
79
+ onChange('')
80
+ }}
81
+ >
82
+ {PANEL_COPY.cancel}
83
+ </button>
84
+ )
85
+ : null}
86
+ </div>
87
+ )
88
+ : (
89
+ <div className="dsh-channels-cfg__inputRow">
90
+ <button type="button" className="dsh-channels-cfg__btn" disabled={disabled} onClick={() => setEditing(true)}>
91
+ {PANEL_COPY.replaceCredential}
92
+ </button>
93
+ <button type="button" className="dsh-channels-cfg__btn dsh-channels-cfg__btn--remove" disabled={disabled} onClick={onClear}>
94
+ {busy === 'credentials-clear' ? PANEL_COPY.removingCredential : PANEL_COPY.removeCredential}
95
+ </button>
96
+ </div>
97
+ )}
98
+ />
99
+ )
100
+ }
101
+
102
+ /** The Feishu application pair (App ID / App Secret). */
103
+ export function CredentialsSection(props: {
14
104
  panel: PanelWire
15
105
  credentials: CredentialsView
16
106
  }): JSX.Element {
@@ -18,87 +108,34 @@ export function CredentialsCard(props: {
18
108
  const { appId, appSecret, issues, busy, locked, onAppIdChange, onAppSecretChange, onSave, onClear } = credentials
19
109
  const appIdState = panel.config.credentials.appId
20
110
  const appSecretState = panel.config.credentials.appSecret
21
- const configured = appIdState.configured && appSecretState.configured
22
- const writable = appIdState.writable || appSecretState.writable
23
- const disabled = busy !== null || locked || !writable
24
- const pending = appId.trim() !== '' || appSecret.trim() !== ''
25
-
26
- const row = (
27
- field: CredentialField,
28
- label: string,
29
- state: CredentialRefWire,
30
- value: string,
31
- type: 'text' | 'password',
32
- placeholder: string,
33
- onChange: (next: string) => void,
34
- ): JSX.Element => {
35
- const issue = issues.find(item => item.key === field)
36
- const inputClass = `dsh-channels-cfg__input${issue === undefined ? '' : ' dsh-channels-cfg__input--invalid'}`
37
- return (
38
- <div className="dsh-channels-cfg__field" key={field}>
39
- <div className="dsh-channels-cfg__labelRow">
40
- <span className="dsh-channels-cfg__label">{label}</span>
41
- <span className={`dsh-channels-cfg__badge dsh-channels-cfg__badge--${state.configured ? 'ok' : 'bad'}`}>
42
- {state.configured ? PANEL_COPY.credentialConfigured : PANEL_COPY.credentialMissing}
43
- </span>
44
- </div>
45
- <div className="dsh-channels-cfg__cred">
46
- <input
47
- type={type}
48
- className={inputClass}
49
- value={value}
50
- placeholder={placeholder}
51
- autoComplete="new-password"
52
- aria-label={label}
53
- disabled={disabled}
54
- onChange={event => onChange(event.target.value)}
55
- />
56
- {state.configured
57
- ? (
58
- <button
59
- type="button"
60
- className="dsh-channels-cfg__btn"
61
- disabled={disabled}
62
- onClick={() => onClear(field)}
63
- >
64
- {busy === 'credentials-clear' ? PANEL_COPY.removingCredential : PANEL_COPY.removeCredential}
65
- </button>
66
- )
67
- : null}
68
- </div>
69
- <span className="dsh-channels-cfg__help">
70
- {PANEL_COPY.credentialsRef}:<span className="dsh-channels-cfg__mono">{state.ref}</span>
71
- {state.source === undefined ? '' : ` · ${state.source}`}
72
- </span>
73
- {issue === undefined ? null : <span className="dsh-channels-cfg__fieldError">{issue.message}</span>}
74
- </div>
75
- )
76
- }
77
-
78
111
  return (
79
- <section className="dsh-channels-cfg__card">
80
- <div className="dsh-channels-cfg__cardHead">
81
- <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.credentialsCard}</span>
82
- <span className={`dsh-channels-cfg__badge dsh-channels-cfg__badge--${configured ? 'ok' : 'muted'}`}>
83
- {configured ? PANEL_COPY.configuredBadge : PANEL_COPY.unconfiguredBadge}
84
- </span>
85
- </div>
86
- <span className="dsh-channels-cfg__help">{PANEL_COPY.credentialsCardHelp}</span>
87
- {row('appId', PANEL_COPY.appIdLabel, appIdState, appId, 'text', PANEL_COPY.appIdPlaceholder, onAppIdChange)}
88
- {row('appSecret', PANEL_COPY.appSecretLabel, appSecretState, appSecret, 'password', PANEL_COPY.appSecretPlaceholder, onAppSecretChange)}
89
- {writable ? null : <span className="dsh-channels-cfg__fieldError">{PANEL_COPY.credentialReadOnly}</span>}
90
- <div className="dsh-channels-cfg__footer">
91
- <button
92
- type="button"
93
- className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
94
- onClick={onSave}
95
- disabled={disabled || !pending}
96
- >
97
- {busy === 'credentials' ? PANEL_COPY.savingCredentials : PANEL_COPY.saveCredentials}
98
- </button>
99
- <span className="dsh-channels-cfg__spacer" />
100
- <span className="dsh-channels-cfg__help">{PANEL_COPY.credentialsPendingHint}</span>
101
- </div>
102
- </section>
112
+ <Section title={PANEL_COPY.credentialsTitle} footnote={PANEL_COPY.credentialsHint}>
113
+ <CredentialRow
114
+ label={PANEL_COPY.appIdLabel}
115
+ state={appIdState}
116
+ value={appId}
117
+ secret={false}
118
+ placeholder={PANEL_COPY.appIdPlaceholder}
119
+ issue={issues.find(item => item.key === 'appId')}
120
+ busy={busy}
121
+ disabled={busy !== null || locked || !appIdState.writable}
122
+ onChange={onAppIdChange}
123
+ onSave={onSave}
124
+ onClear={() => onClear('appId')}
125
+ />
126
+ <CredentialRow
127
+ label={PANEL_COPY.appSecretLabel}
128
+ state={appSecretState}
129
+ value={appSecret}
130
+ secret
131
+ placeholder={PANEL_COPY.appSecretPlaceholder}
132
+ issue={issues.find(item => item.key === 'appSecret')}
133
+ busy={busy}
134
+ disabled={busy !== null || locked || !appSecretState.writable}
135
+ onChange={onAppSecretChange}
136
+ onSave={onSave}
137
+ onClear={() => onClear('appSecret')}
138
+ />
139
+ </Section>
103
140
  )
104
141
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Tabs made only of Host fields (「会话」「模型」「附件与工具」): one titled
3
+ * section per field group the tab owns (`TAB_FIELD_GROUPS`).
4
+ *
5
+ * @module dsh-channels/client/panel/fields-tab
6
+ */
7
+
8
+ import type { ReactNode } from 'react'
9
+ import { PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
10
+ import type { FieldGroup, PanelTabId } from '../../settings/contract.ts'
11
+ import { FieldSections } from './shared.tsx'
12
+ import type { FormView } from './shared.tsx'
13
+
14
+ /** Footnotes under one group's section, per group. */
15
+ const FOOTNOTES: Partial<Record<FieldGroup, ReactNode>> = {
16
+ model: PANEL_COPY.modelFootnote,
17
+ }
18
+
19
+ export function FieldsTab(props: { form: FormView; tab: PanelTabId; note?: string }): JSX.Element {
20
+ const { form, tab, note } = props
21
+ return (
22
+ <div className="dsh-channels-cfg__tabBody">
23
+ {note === undefined ? null : <p className="dsh-channels-cfg__secFoot">{note}</p>}
24
+ <FieldSections form={form} groups={TAB_FIELD_GROUPS[tab]} footnotes={FOOTNOTES} />
25
+ </div>
26
+ )
27
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * 设置 →「飞书渠道」页面:数据加载、5 秒状态轮询、全局 draft 与顶部状态头 /
3
- * tab 导航的外壳。
2
+ * 设置 →「飞书渠道」页面:数据加载、5 秒状态轮询、全局 draft、顶部状态头 /
3
+ * tab 导航与底部保存条的外壳。
4
4
  *
5
5
  * 所有 Host 交互(panel / status / catalog / config / credentials / access /
6
6
  * pick-directory)都集中在这里;tab 组件只接收渲染所需的数据与
@@ -14,6 +14,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
14
14
  import type { KeyboardEvent } from 'react'
15
15
  import { PANEL_COPY, PANEL_TABS, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
16
16
  import type {
17
+ AccessMutationResponse,
17
18
  AccessWire,
18
19
  CatalogWire,
19
20
  ChannelStatusWire,
@@ -28,6 +29,7 @@ import type {
28
29
  import {
29
30
  addAccessGroup,
30
31
  addAccessUser,
32
+ addWikiUser,
31
33
  clearConfig,
32
34
  clearCredential,
33
35
  fetchCatalog,
@@ -37,6 +39,7 @@ import {
37
39
  reconnect,
38
40
  removeAccessGroup,
39
41
  removeAccessUser,
42
+ removeWikiUser,
40
43
  resolveAccessRequest,
41
44
  saveConfig,
42
45
  saveCredentials,
@@ -45,24 +48,26 @@ import { applyCatalogFields, catalogFromFields } from '../catalog.ts'
45
48
  import { AccessTab } from './access-tab.tsx'
46
49
  import { AdvancedTab } from './advanced-tab.tsx'
47
50
  import { ConnectionTab } from './connection-tab.tsx'
48
- import { ModelTab } from './model-tab.tsx'
49
- import { SessionsTab } from './sessions-tab.tsx'
51
+ import { FieldsTab } from './fields-tab.tsx'
50
52
  import {
51
53
  fieldLabel,
54
+ formatUptime,
52
55
  messageOf,
53
56
  NoticeBar,
54
57
  phaseBadge,
55
- phaseSummary,
58
+ Pill,
59
+ SaveBar,
56
60
  validateDraft,
57
61
  } from './shared.tsx'
58
62
  import type {
63
+ AnalyzeTokenView,
59
64
  BusyAction,
60
65
  CredentialsView,
61
66
  FormView,
62
67
  LoadState,
63
68
  Notice,
64
69
  } from './shared.tsx'
65
- import { WorkspaceTab } from './workspace-tab.tsx'
70
+ import { WikiTab } from './wiki-tab.tsx'
66
71
 
67
72
  /** Owner props handed to a `settings.section` contribution (unused beyond parity). */
68
73
  export interface FeishuChannelsSectionProps {
@@ -80,6 +85,7 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
80
85
  const [pollError, setPollError] = useState<string | null>(null)
81
86
  const [appIdInput, setAppIdInput] = useState('')
82
87
  const [appSecretInput, setAppSecretInput] = useState('')
88
+ const [analyzeTokenInput, setAnalyzeTokenInput] = useState('')
83
89
  const [catalog, setCatalog] = useState<CatalogWire | null>(null)
84
90
  const [catalogBusy, setCatalogBusy] = useState(false)
85
91
  const [accessNotice, setAccessNotice] = useState<string | null>(null)
@@ -106,6 +112,7 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
106
112
  setDraft({})
107
113
  setAppIdInput('')
108
114
  setAppSecretInput('')
115
+ setAnalyzeTokenInput('')
109
116
  } catch (error) {
110
117
  setLoad({ phase: 'error', message: messageOf(error) })
111
118
  }
@@ -309,24 +316,14 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
309
316
  }
310
317
  }, [draft, panel])
311
318
 
312
- /** Drop the draft keys owned by one tab (nothing is written). */
313
- const discardTab = useCallback((tab: PanelTabId): void => {
319
+ /** Drop the whole draft (nothing is written). */
320
+ const discardDraft = useCallback((): void => {
314
321
  if (panel === null) return
315
- const groups = new Set<string>(TAB_FIELD_GROUPS[tab])
316
- const keys = new Set(
317
- panel.config.fields.filter(field => groups.has(field.group)).map(field => field.key),
318
- )
319
- setDraft((previous) => {
320
- const next = { ...previous }
321
- for (const key of keys) delete next[key]
322
- return next
323
- })
322
+ setDraft({})
324
323
  setNotice(null)
325
- if (tab === 'model') {
326
- catalogTicket.current += 1
327
- setCatalog(catalogFromFields(panel.config.fields))
328
- setCatalogBusy(false)
329
- }
324
+ catalogTicket.current += 1
325
+ setCatalog(catalogFromFields(panel.config.fields))
326
+ setCatalogBusy(false)
330
327
  }, [panel])
331
328
 
332
329
  /**
@@ -352,6 +349,10 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
352
349
 
353
350
  /** Drop every Web override and return the refreshed panel. */
354
351
  const onClear = useCallback(async (): Promise<void> => {
352
+ const confirmed = typeof window.confirm === 'function'
353
+ ? window.confirm(PANEL_COPY.clearConfirm)
354
+ : true
355
+ if (!confirmed) return
355
356
  setBusy('clear')
356
357
  setNotice(null)
357
358
  try {
@@ -453,18 +454,24 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
453
454
  }
454
455
  }, [applyAccess])
455
456
 
456
- const onAddAccessUser = useCallback(async (userId: string, name: string): Promise<boolean> => {
457
+ /** Add one user to a whitelist; resolves true when the input should clear. */
458
+ const addUser = useCallback(async (
459
+ add: (userId: string, name: string) => Promise<AccessMutationResponse>,
460
+ userId: string,
461
+ name: string,
462
+ notice: string,
463
+ ): Promise<boolean> => {
457
464
  setBusy('access')
458
465
  setAccessError(null)
459
466
  setAccessNotice(null)
460
467
  try {
461
- const result = await addAccessUser(userId, name)
468
+ const result = await add(userId, name)
462
469
  if (!result.ok) {
463
470
  setAccessError(result.error)
464
471
  return false
465
472
  }
466
473
  applyAccess(result.access)
467
- setAccessNotice(PANEL_COPY.accessUserAdded)
474
+ setAccessNotice(notice)
468
475
  return true
469
476
  } catch (error) {
470
477
  setAccessError(messageOf(error))
@@ -474,18 +481,22 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
474
481
  }
475
482
  }, [applyAccess])
476
483
 
477
- const onRemoveAccessUser = useCallback(async (userId: string): Promise<void> => {
484
+ const removeUser = useCallback(async (
485
+ remove: (userId: string) => Promise<AccessMutationResponse>,
486
+ userId: string,
487
+ notice: string,
488
+ ): Promise<void> => {
478
489
  setBusy('access')
479
490
  setAccessError(null)
480
491
  setAccessNotice(null)
481
492
  try {
482
- const result = await removeAccessUser(userId)
493
+ const result = await remove(userId)
483
494
  if (!result.ok) {
484
495
  setAccessError(result.error)
485
496
  return
486
497
  }
487
498
  applyAccess(result.access)
488
- setAccessNotice(PANEL_COPY.accessUserRemoved)
499
+ setAccessNotice(notice)
489
500
  } catch (error) {
490
501
  setAccessError(messageOf(error))
491
502
  } finally {
@@ -552,6 +563,28 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
552
563
  }
553
564
  }, [appIdInput, appSecretInput])
554
565
 
566
+ /** Save the analyze API token; it applies to the next request, no reconnect. */
567
+ const onSaveAnalyzeToken = useCallback(async (): Promise<void> => {
568
+ if (analyzeTokenInput.trim() === '') return
569
+ setBusy('credentials')
570
+ setNotice(null)
571
+ try {
572
+ const result = await saveCredentials({ analyzeToken: analyzeTokenInput })
573
+ if (!result.ok) {
574
+ setNotice({ kind: 'error', text: result.error, issues: result.issues })
575
+ return
576
+ }
577
+ setLoad({ phase: 'ready', panel: result.panel })
578
+ setStatus(result.panel.status)
579
+ setAnalyzeTokenInput('')
580
+ setNotice({ kind: 'ok', text: PANEL_COPY.analyzeTokenSaved })
581
+ } catch (error) {
582
+ setNotice({ kind: 'error', text: messageOf(error) })
583
+ } finally {
584
+ setBusy(null)
585
+ }
586
+ }, [analyzeTokenInput])
587
+
555
588
  const onClearCredential = useCallback(async (field: CredentialField): Promise<void> => {
556
589
  const confirmed = typeof window.confirm === 'function'
557
590
  ? window.confirm(PANEL_COPY.credentialRemoveConfirm)
@@ -569,7 +602,9 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
569
602
  setStatus(result.panel.status)
570
603
  setNotice({
571
604
  kind: 'ok',
572
- text: result.reconnect === 'pending' ? PANEL_COPY.credentialRemovedPending : PANEL_COPY.credentialRemoved,
605
+ text: field === 'analyzeToken'
606
+ ? PANEL_COPY.analyzeTokenRemoved
607
+ : result.reconnect === 'pending' ? PANEL_COPY.credentialRemovedPending : PANEL_COPY.credentialRemoved,
573
608
  })
574
609
  } catch (error) {
575
610
  setNotice({ kind: 'error', text: messageOf(error) })
@@ -583,6 +618,10 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
583
618
  [notice],
584
619
  )
585
620
  const dirtyCount = Object.keys(draft).length
621
+ const restartCount = useMemo(
622
+ () => (panel?.config.fields ?? []).filter(field => field.restartRequired && field.key in draft).length,
623
+ [draft, panel],
624
+ )
586
625
 
587
626
  /** Which tabs currently own a dirty key (the dot is per tab, not global). */
588
627
  const dirtyTabs = useMemo<ReadonlySet<PanelTabId>>(() => {
@@ -608,13 +647,10 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
608
647
  issues,
609
648
  busy,
610
649
  locked: hostBusy,
611
- dirtyCount,
612
650
  onChange: updateDraft,
613
- onSave: () => { void onSave() },
614
- onDiscardTab: discardTab,
615
651
  onPickDirectory,
616
652
  }
617
- }, [busy, discardTab, dirtyCount, draft, hostBusy, issues, onPickDirectory, onSave, panel, renderedFields, updateDraft])
653
+ }, [busy, draft, hostBusy, issues, onPickDirectory, panel, renderedFields, updateDraft])
618
654
 
619
655
  const credentials = useMemo<CredentialsView>(() => ({
620
656
  appId: appIdInput,
@@ -628,6 +664,16 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
628
664
  onClear: (field: CredentialField) => { void onClearCredential(field) },
629
665
  }), [appIdInput, appSecretInput, busy, hostBusy, issues, onClearCredential, onSaveCredentials])
630
666
 
667
+ const analyzeToken = useMemo<AnalyzeTokenView>(() => ({
668
+ value: analyzeTokenInput,
669
+ issues,
670
+ busy,
671
+ locked: hostBusy,
672
+ onChange: setAnalyzeTokenInput,
673
+ onSave: () => { void onSaveAnalyzeToken() },
674
+ onClear: () => { void onClearCredential('analyzeToken') },
675
+ }), [analyzeTokenInput, busy, hostBusy, issues, onClearCredential, onSaveAnalyzeToken])
676
+
631
677
  if (load.phase === 'loading') {
632
678
  return (
633
679
  <div className="dsh-channels-cfg">
@@ -662,8 +708,14 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
662
708
  locked={hostBusy}
663
709
  onRefresh={() => { void onRefresh() }}
664
710
  />
665
- <TabNav activeTab={activeTab} dirtyTabs={dirtyTabs} onChange={setActiveTab} />
666
- {notice === null ? null : <NoticeBar notice={notice} />}
711
+ <TabNav
712
+ activeTab={activeTab}
713
+ dirtyTabs={dirtyTabs}
714
+ counts={{ access: load.panel.access.loaded ? load.panel.access.requests.length : 0 }}
715
+ onChange={setActiveTab}
716
+ />
717
+ {load.panel.access.mode === 'open' ? <div className="dsh-channels-cfg__banner">{PANEL_COPY.debugModeWarning}</div> : null}
718
+ {notice === null ? null : <NoticeBar kind={notice.kind} text={notice.text} />}
667
719
  <div
668
720
  role="tabpanel"
669
721
  id={`dsh-channels-tabpanel-${activeTab}`}
@@ -680,13 +732,11 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
680
732
  />
681
733
  )
682
734
  : null}
683
- {activeTab === 'sessions' ? <SessionsTab form={form} /> : null}
684
- {activeTab === 'model' ? <ModelTab form={form} catalogBusy={catalogBusy} /> : null}
685
- {activeTab === 'workspace'
686
- ? (
687
- <WorkspaceTab form={form} />
688
- )
735
+ {activeTab === 'sessions' ? <FieldsTab form={form} tab="sessions" /> : null}
736
+ {activeTab === 'model'
737
+ ? <FieldsTab form={form} tab="model" note={catalogBusy ? PANEL_COPY.catalogLoading : undefined} />
689
738
  : null}
739
+ {activeTab === 'attachments' ? <FieldsTab form={form} tab="attachments" /> : null}
690
740
  {activeTab === 'access'
691
741
  ? (
692
742
  <AccessTab
@@ -695,21 +745,40 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
695
745
  accessError={accessError}
696
746
  onAddGroup={onAddAccessGroup}
697
747
  onRemoveGroup={chatId => { void onRemoveAccessGroup(chatId) }}
698
- onAddUser={onAddAccessUser}
699
- onRemoveUser={userId => { void onRemoveAccessUser(userId) }}
748
+ onAddUser={(userId, name) => addUser(addAccessUser, userId, name, PANEL_COPY.accessUserAdded)}
749
+ onRemoveUser={userId => { void removeUser(removeAccessUser, userId, PANEL_COPY.accessUserRemoved) }}
700
750
  onResolveRequest={(token, decision) => { void onResolveAccessRequest(token, decision) }}
701
751
  />
702
752
  )
703
753
  : null}
754
+ {activeTab === 'wiki'
755
+ ? (
756
+ <WikiTab
757
+ form={form}
758
+ accessNotice={accessNotice}
759
+ accessError={accessError}
760
+ onAddUser={(userId, name) => addUser(addWikiUser, userId, name, PANEL_COPY.wikiUserAdded)}
761
+ onRemoveUser={userId => { void removeUser(removeWikiUser, userId, PANEL_COPY.wikiUserRemoved) }}
762
+ />
763
+ )
764
+ : null}
704
765
  {activeTab === 'advanced'
705
- ? <AdvancedTab form={form} onClear={() => { void onClear() }} />
766
+ ? <AdvancedTab form={form} analyzeToken={analyzeToken} onClear={() => { void onClear() }} />
706
767
  : null}
707
768
  </div>
769
+ <SaveBar
770
+ dirtyCount={dirtyCount}
771
+ restartCount={restartCount}
772
+ busy={busy}
773
+ locked={hostBusy}
774
+ onSave={() => { void onSave() }}
775
+ onDiscard={discardDraft}
776
+ />
708
777
  </div>
709
778
  )
710
779
  }
711
780
 
712
- /** Page header: title + phase badge + refresh, visible on every tab. */
781
+ /** Page header: title, phase pill, version and uptime, refresh; visible on every tab. */
713
782
  function PanelHeader(props: {
714
783
  status: ChannelStatusWire
715
784
  pollError: string | null
@@ -720,24 +789,24 @@ function PanelHeader(props: {
720
789
  const { status, pollError, busy, locked, onRefresh } = props
721
790
  const badge = phaseBadge(status.phase)
722
791
  return (
723
- <header className="dsh-channels-cfg__panelHead" title={PANEL_COPY.description}>
792
+ <header className="dsh-channels-cfg__panelHead">
724
793
  <div className="dsh-channels-cfg__headRow">
725
794
  <h2 className="dsh-channels-cfg__title">{PANEL_COPY.title}</h2>
726
- <span className={`dsh-channels-cfg__badge dsh-channels-cfg__badge--${badge.tone}`}>
727
- <span className="dsh-channels-cfg__dot" />
728
- {badge.text}
729
- </span>
795
+ <Pill tone={badge.tone} dot>{badge.text}</Pill>
796
+ <span className="dsh-channels-cfg__meta dsh-channels-cfg__mono">v{status.version}</span>
797
+ {status.uptimeMs === undefined
798
+ ? null
799
+ : <span className="dsh-channels-cfg__meta">· {PANEL_COPY.uptimePrefix} {formatUptime(status.uptimeMs)}</span>}
730
800
  <span className="dsh-channels-cfg__spacer" />
731
801
  <button
732
802
  type="button"
733
- className="dsh-channels-cfg__btn"
803
+ className="dsh-channels-cfg__btn dsh-channels-cfg__btn--ghost"
734
804
  onClick={onRefresh}
735
805
  disabled={busy !== null || locked}
736
806
  >
737
807
  {PANEL_COPY.refresh}
738
808
  </button>
739
809
  </div>
740
- <p className="dsh-channels-cfg__summary">{phaseSummary(status.phase)}</p>
741
810
  {pollError === null
742
811
  ? null
743
812
  : (
@@ -753,9 +822,11 @@ function PanelHeader(props: {
753
822
  function TabNav(props: {
754
823
  activeTab: PanelTabId
755
824
  dirtyTabs: ReadonlySet<PanelTabId>
825
+ /** Items waiting for the operator per tab (pending access requests). */
826
+ counts: Partial<Record<PanelTabId, number>>
756
827
  onChange: (tab: PanelTabId) => void
757
828
  }): JSX.Element {
758
- const { activeTab, dirtyTabs, onChange } = props
829
+ const { activeTab, dirtyTabs, counts, onChange } = props
759
830
  const refs = useRef<Array<HTMLButtonElement | null>>([])
760
831
 
761
832
  /** Roving-focus keyboard navigation between tabs (WAI-ARIA tabs pattern). */
@@ -794,12 +865,12 @@ function TabNav(props: {
794
865
  onKeyDown={event => onKeyDown(index, event)}
795
866
  >
796
867
  {tab.label}
868
+ {(counts[tab.id] ?? 0) > 0 ? <span className="dsh-channels-cfg__tabCount">{counts[tab.id]}</span> : null}
797
869
  {dirty
798
870
  ? (
799
871
  <span
800
872
  className="dsh-channels-cfg__tabDot"
801
- title={PANEL_COPY.dirtyCount}
802
- aria-label={PANEL_COPY.dirtyCount}
873
+ aria-hidden="true"
803
874
  />
804
875
  )
805
876
  : null}