@modesto_ma/dsh-channels 0.4.0 → 0.5.0-beta.2

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 (66) hide show
  1. package/README.md +83 -57
  2. package/docs/access-control-design.md +8 -5
  3. package/docs/attachment-pipeline-v2.md +33 -41
  4. package/docs/feishu-onboarding.md +36 -33
  5. package/docs/persistence.md +6 -4
  6. package/docs/refactor-plan.md +1 -1
  7. package/docs/workspace-isolation-design.md +7 -8
  8. package/lib/client.js +444 -288
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +9017 -6794
  11. package/lib/index.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/access/cards.ts +9 -9
  14. package/src/access/controller.ts +10 -10
  15. package/src/access/index.ts +1 -0
  16. package/src/access/policy.ts +10 -10
  17. package/src/access/requests.ts +32 -31
  18. package/src/access/store.ts +43 -13
  19. package/src/access/targets.ts +56 -0
  20. package/src/cards/approval.ts +96 -26
  21. package/src/cards/index.ts +1 -0
  22. package/src/cards/onboarding.ts +1 -0
  23. package/src/cards/picker.ts +108 -0
  24. package/src/cards/streaming.ts +56 -7
  25. package/src/cards/wiki.ts +124 -0
  26. package/src/client/api.ts +5 -25
  27. package/src/client/panel/access-card.tsx +24 -15
  28. package/src/client/panel/access-tab.tsx +3 -3
  29. package/src/client/panel/connection-tab.tsx +70 -1
  30. package/src/client/panel/index.tsx +11 -62
  31. package/src/client/panel/shared.tsx +96 -24
  32. package/src/client/panel/workspace-tab.tsx +2 -18
  33. package/src/client/string-list.ts +139 -0
  34. package/src/client/styles.ts +5 -0
  35. package/src/commands/registry.ts +8 -5
  36. package/src/config.ts +55 -54
  37. package/src/domain.ts +36 -123
  38. package/src/feishu/delivery.ts +34 -1
  39. package/src/feishu/inbound-attachments.ts +1 -1
  40. package/src/feishu/manifest.ts +8 -31
  41. package/src/feishu/message-read.ts +97 -24
  42. package/src/feishu/scopes.ts +194 -0
  43. package/src/feishu/send-file.ts +543 -0
  44. package/src/feishu/slash-commands.ts +134 -0
  45. package/src/feishu/tools.ts +72 -112
  46. package/src/feishu/wiki/api.ts +346 -0
  47. package/src/feishu/wiki/consent.ts +183 -0
  48. package/src/feishu/wiki/links.ts +122 -0
  49. package/src/feishu/wiki/scope.ts +66 -0
  50. package/src/feishu/wiki/tools.ts +984 -0
  51. package/src/feishu-channel.ts +280 -9
  52. package/src/index.ts +5 -4
  53. package/src/interactions/approvals.ts +187 -7
  54. package/src/interactions/pending.ts +3 -3
  55. package/src/routing/inbound.ts +73 -106
  56. package/src/service.ts +545 -440
  57. package/src/session/manager.ts +1 -1
  58. package/src/session/routes.ts +2 -3
  59. package/src/settings/contract.ts +110 -89
  60. package/src/settings/routes.ts +3 -17
  61. package/src/settings/surface.ts +144 -94
  62. package/src/streamer.ts +94 -5
  63. package/src/workspaces.ts +21 -29
  64. package/src/cleanup.ts +0 -546
  65. package/src/client/panel/cleanup-card.tsx +0 -106
  66. package/src/feishu/pending-attachments.ts +0 -562
@@ -44,8 +44,8 @@ export interface AccessCardProps {
44
44
  error: string | null
45
45
  onAddGroup: (chatId: string, name: string) => Promise<boolean>
46
46
  onRemoveGroup: (chatId: string) => void
47
- onAddUser: (openId: string, name: string) => Promise<boolean>
48
- onRemoveUser: (openId: string) => void
47
+ onAddUser: (userId: string, name: string) => Promise<boolean>
48
+ onRemoveUser: (userId: string) => void
49
49
  onResolveRequest: (token: string, decision: 'allow' | 'deny') => void
50
50
  }
51
51
 
@@ -54,7 +54,7 @@ export function AccessCard(props: AccessCardProps): JSX.Element {
54
54
  const { access } = panel
55
55
  const [groupChatId, setGroupChatId] = useState('')
56
56
  const [groupName, setGroupName] = useState('')
57
- const [userOpenId, setUserOpenId] = useState('')
57
+ const [userUserId, setUserUserId] = useState('')
58
58
  const [userName, setUserName] = useState('')
59
59
  const groups: readonly AccessGroupWire[] = access.groups
60
60
  const users: readonly AccessUserWire[] = access.users
@@ -71,9 +71,9 @@ export function AccessCard(props: AccessCardProps): JSX.Element {
71
71
  }
72
72
 
73
73
  const submitUser = async (): Promise<void> => {
74
- if (userOpenId.trim() === '') return
75
- if (await onAddUser(userOpenId, userName)) {
76
- setUserOpenId('')
74
+ if (userUserId.trim() === '') return
75
+ if (await onAddUser(userUserId, userName)) {
76
+ setUserUserId('')
77
77
  setUserName('')
78
78
  }
79
79
  }
@@ -93,6 +93,15 @@ export function AccessCard(props: AccessCardProps): JSX.Element {
93
93
  ? (
94
94
  <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--error">
95
95
  <strong>{PANEL_COPY.accessAdminMissingWarning}</strong>
96
+ {access.admin.ownerLookupError === undefined ? null : <div>{access.admin.ownerLookupError}</div>}
97
+ </div>
98
+ )
99
+ : null}
100
+ {access.mode === 'guarded' && access.admin.source === 'owner' && access.admin.userId !== undefined
101
+ ? (
102
+ <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--ok">
103
+ {PANEL_COPY.accessAdminOwnerNotice}
104
+ <span className="dsh-channels-cfg__mono">{access.admin.userId}</span>
96
105
  </div>
97
106
  )
98
107
  : null}
@@ -171,11 +180,11 @@ export function AccessCard(props: AccessCardProps): JSX.Element {
171
180
  : (
172
181
  <ul className="dsh-channels-cfg__accessList">
173
182
  {users.map(user => (
174
- <li className="dsh-channels-cfg__accessRow" key={user.openId}>
183
+ <li className="dsh-channels-cfg__accessRow" key={user.userId}>
175
184
  <span className="dsh-channels-cfg__accessMain">
176
- <span>{user.name ?? user.openId}</span>
185
+ <span>{user.name ?? user.userId}</span>
177
186
  <span className="dsh-channels-cfg__accessMeta">
178
- <span className="dsh-channels-cfg__mono">{user.openId}</span>
187
+ <span className="dsh-channels-cfg__mono">{user.userId}</span>
179
188
  {` · ${accessSourceLabel(user.source)} · ${formatAccessDate(user.addedAt)}`}
180
189
  </span>
181
190
  </span>
@@ -183,7 +192,7 @@ export function AccessCard(props: AccessCardProps): JSX.Element {
183
192
  type="button"
184
193
  className="dsh-channels-cfg__btn"
185
194
  disabled={disabled}
186
- onClick={() => onRemoveUser(user.openId)}
195
+ onClick={() => onRemoveUser(user.userId)}
187
196
  >
188
197
  {working ? PANEL_COPY.accessWorking : PANEL_COPY.accessRemove}
189
198
  </button>
@@ -195,11 +204,11 @@ export function AccessCard(props: AccessCardProps): JSX.Element {
195
204
  <input
196
205
  type="text"
197
206
  className="dsh-channels-cfg__input dsh-channels-cfg__accessInput"
198
- value={userOpenId}
199
- placeholder={PANEL_COPY.accessUserOpenIdPlaceholder}
200
- aria-label={PANEL_COPY.accessUserOpenIdPlaceholder}
207
+ value={userUserId}
208
+ placeholder={PANEL_COPY.accessUserIdPlaceholder}
209
+ aria-label={PANEL_COPY.accessUserIdPlaceholder}
201
210
  disabled={disabled}
202
- onChange={event => setUserOpenId(event.target.value)}
211
+ onChange={event => setUserUserId(event.target.value)}
203
212
  />
204
213
  <input
205
214
  type="text"
@@ -213,7 +222,7 @@ export function AccessCard(props: AccessCardProps): JSX.Element {
213
222
  <button
214
223
  type="button"
215
224
  className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
216
- disabled={disabled || userOpenId.trim() === ''}
225
+ disabled={disabled || userUserId.trim() === ''}
217
226
  onClick={() => { void submitUser() }}
218
227
  >
219
228
  {working ? PANEL_COPY.accessWorking : PANEL_COPY.accessAddUser}
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 「访问授权」tab:准入与授权字段表单(管理员 open_id / 准入模式 /
2
+ * 「访问授权」tab:准入与授权字段表单(管理员 user_id / 准入模式 /
3
3
  * 申请有效期)+ 白名单与待处理申请卡。表单保存走全局 draft,白名单
4
4
  * 增删是独立即时 mutation。
5
5
  *
@@ -17,8 +17,8 @@ export interface AccessTabProps {
17
17
  accessError: string | null
18
18
  onAddGroup: (chatId: string, name: string) => Promise<boolean>
19
19
  onRemoveGroup: (chatId: string) => void
20
- onAddUser: (openId: string, name: string) => Promise<boolean>
21
- onRemoveUser: (openId: string) => void
20
+ onAddUser: (userId: string, name: string) => Promise<boolean>
21
+ onRemoveUser: (userId: string) => void
22
22
  onResolveRequest: (token: string, decision: 'allow' | 'deny') => void
23
23
  }
24
24
 
@@ -6,12 +6,13 @@
6
6
  * - error:连接信息卡 + 最近一次连接失败 + 「重试」
7
7
  * - connected:连接信息卡 + 「重启飞书连接」+ 凭据卡
8
8
  * - connecting:禁用按钮显示「连接中…」
9
+ * - 已连接且权限自检完成:权限自检卡(缺失权限红字 + 开发者后台深链)
9
10
  *
10
11
  * @module dsh-channels/client/panel/connection-tab
11
12
  */
12
13
 
13
14
  import { PANEL_COPY } from '../../settings/contract.ts'
14
- import type { ChannelStatusWire, PanelWire } from '../../settings/contract.ts'
15
+ import type { ChannelStatusWire, PanelWire, ScopeCheckWire, SlashCommandSyncWire } from '../../settings/contract.ts'
15
16
  import { CredentialsCard } from './credentials-card.tsx'
16
17
  import { connectionStateLabel, fieldLabel, formatUptime, phaseBadge, TabHelp } from './shared.tsx'
17
18
  import type { BusyAction, CredentialsView } from './shared.tsx'
@@ -35,6 +36,8 @@ export function ConnectionTab(props: ConnectionTabProps): JSX.Element {
35
36
  {status.phase === 'unconfigured'
36
37
  ? <OnboardingCard status={status} />
37
38
  : <ConnectionInfoCard status={status} busy={busy} onReconnect={onReconnect} />}
39
+ {status.scopeCheck === undefined ? null : <ScopeCheckCard check={status.scopeCheck} />}
40
+ {status.slashCommandSync === undefined ? null : <SlashCommandSyncCard sync={status.slashCommandSync} />}
38
41
  <CredentialsCard panel={panel} credentials={credentials} />
39
42
  </div>
40
43
  )
@@ -186,6 +189,72 @@ function ConnectionInfoCard(props: {
186
189
  )
187
190
  }
188
191
 
192
+ /** Post-connect scope self-check: missing scopes in red, each with a console deep link. */
193
+ function ScopeCheckCard(props: { check: ScopeCheckWire }): JSX.Element {
194
+ const { check } = props
195
+ return (
196
+ <section className="dsh-channels-cfg__card">
197
+ <div className="dsh-channels-cfg__cardHead">
198
+ <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.scopeCheckTitle}</span>
199
+ </div>
200
+ {check.state === 'ok'
201
+ ? <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--ok">{PANEL_COPY.scopeCheckOk}</div>
202
+ : null}
203
+ {check.state === 'error'
204
+ ? (
205
+ <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--error">
206
+ <strong>{PANEL_COPY.scopeCheckError}</strong>
207
+ <span className="dsh-channels-cfg__mono">{check.error}</span>
208
+ </div>
209
+ )
210
+ : null}
211
+ {check.state === 'missing'
212
+ ? (
213
+ <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--error">
214
+ <strong>{PANEL_COPY.scopeCheckMissing}</strong>
215
+ <ul className="dsh-channels-cfg__scopeList">
216
+ {check.missing.map(item => (
217
+ <li key={item.scopes.join(',')}>
218
+ <span>[{item.level === 'required' ? PANEL_COPY.scopeCheckRequired : PANEL_COPY.scopeCheckOptional}] </span>
219
+ <span className="dsh-channels-cfg__mono">{item.scopes.join(' / ')}</span>
220
+ <span>:{item.purpose} </span>
221
+ <a href={item.consoleUrl} target="_blank" rel="noreferrer noopener">{PANEL_COPY.scopeCheckOpenOne}</a>
222
+ </li>
223
+ ))}
224
+ </ul>
225
+ <a href={check.consoleUrl} target="_blank" rel="noreferrer noopener">{PANEL_COPY.scopeCheckOpenAll}</a>
226
+ </div>
227
+ )
228
+ : null}
229
+ </section>
230
+ )
231
+ }
232
+
233
+ /** Post-connect native slash-command panel sync: counts on success, the Feishu reason on failure. */
234
+ function SlashCommandSyncCard(props: { sync: SlashCommandSyncWire }): JSX.Element {
235
+ const { sync } = props
236
+ return (
237
+ <section className="dsh-channels-cfg__card">
238
+ <div className="dsh-channels-cfg__cardHead">
239
+ <span className="dsh-channels-cfg__cardTitle">{PANEL_COPY.slashCommandSyncTitle}</span>
240
+ </div>
241
+ {sync.state === 'ok'
242
+ ? (
243
+ <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--ok">
244
+ <span>{PANEL_COPY.slashCommandSyncOk} </span>
245
+ <span>{PANEL_COPY.slashCommandSyncTotal} {sync.total} · {PANEL_COPY.slashCommandSyncCreated} {sync.created} · {PANEL_COPY.slashCommandSyncUpdated} {sync.updated} · {PANEL_COPY.slashCommandSyncRemoved} {sync.removed}</span>
246
+ </div>
247
+ )
248
+ : (
249
+ <div className="dsh-channels-cfg__notice dsh-channels-cfg__notice--error">
250
+ <strong>{PANEL_COPY.slashCommandSyncError}</strong>
251
+ <span className="dsh-channels-cfg__mono">{sync.error}</span>
252
+ </div>
253
+ )}
254
+ </section>
255
+ )
256
+ }
257
+
189
258
  /** Config saved but only applied on the next connect: offer the one-click restart. */
190
259
  function RestartBanner(props: {
191
260
  panel: PanelWire
@@ -2,8 +2,8 @@
2
2
  * 设置 →「飞书渠道」页面:数据加载、5 秒状态轮询、全局 draft 与顶部状态头 /
3
3
  * tab 导航的外壳。
4
4
  *
5
- * 所有 Host 交互(panel / status / catalog / config / credentials / cleanup /
6
- * access / pick-directory)都集中在这里;tab 组件只接收渲染所需的数据与
5
+ * 所有 Host 交互(panel / status / catalog / config / credentials / access /
6
+ * pick-directory)都集中在这里;tab 组件只接收渲染所需的数据与
7
7
  * 回调。页面不 import 任何 DSH 客户端包,只依赖 react 与 `settings.section`
8
8
  * 槽位。
9
9
  *
@@ -17,7 +17,6 @@ import type {
17
17
  AccessWire,
18
18
  CatalogWire,
19
19
  ChannelStatusWire,
20
- CleanupRunWire,
21
20
  ConfigValue,
22
21
  CredentialField,
23
22
  EditableKey,
@@ -39,7 +38,6 @@ import {
39
38
  removeAccessGroup,
40
39
  removeAccessUser,
41
40
  resolveAccessRequest,
42
- runCleanup,
43
41
  saveConfig,
44
42
  saveCredentials,
45
43
  } from '../api.ts'
@@ -51,7 +49,6 @@ import { ModelTab } from './model-tab.tsx'
51
49
  import { SessionsTab } from './sessions-tab.tsx'
52
50
  import {
53
51
  fieldLabel,
54
- formatBytes,
55
52
  messageOf,
56
53
  NoticeBar,
57
54
  phaseBadge,
@@ -85,8 +82,6 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
85
82
  const [appSecretInput, setAppSecretInput] = useState('')
86
83
  const [catalog, setCatalog] = useState<CatalogWire | null>(null)
87
84
  const [catalogBusy, setCatalogBusy] = useState(false)
88
- const [cleanupResult, setCleanupResult] = useState<CleanupRunWire | null>(null)
89
- const [cleanupError, setCleanupError] = useState<string | null>(null)
90
85
  const [accessNotice, setAccessNotice] = useState<string | null>(null)
91
86
  const [accessError, setAccessError] = useState<string | null>(null)
92
87
  const catalogTicket = useRef(0)
@@ -102,8 +97,6 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
102
97
  const loadPanel = useCallback(async (): Promise<void> => {
103
98
  setLoad({ phase: 'loading' })
104
99
  setNotice(null)
105
- setCleanupResult(null)
106
- setCleanupError(null)
107
100
  setAccessNotice(null)
108
101
  setAccessError(null)
109
102
  try {
@@ -145,7 +138,7 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
145
138
  setPollError(null)
146
139
  // A phase transition changes what the panel payload can offer: the
147
140
  // storage domain opens/closes with the connection, so the access
148
- // whitelists and the cleanup preview go stale. Re-read the panel but
141
+ // whitelists go stale. Re-read the panel but
149
142
  // never touch the dirty draft.
150
143
  if (previousPhase !== undefined && previousPhase !== next.phase) {
151
144
  try {
@@ -260,8 +253,8 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
260
253
  }, [])
261
254
 
262
255
  /**
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
256
+ * Manual refresh: reload the whole panel payload so access lists and
257
+ * `restartPendingKeys` catch up with the Host. The dirty draft is
265
258
  * deliberately preserved — refreshing never discards user input.
266
259
  */
267
260
  const onRefresh = useCallback(async (): Promise<void> => {
@@ -301,8 +294,6 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
301
294
  }
302
295
  setLoad({ phase: 'ready', panel: result.panel })
303
296
  setStatus(result.panel.status)
304
- setCleanupResult(null)
305
- setCleanupError(null)
306
297
  // Clear only the submitted keys: edits typed while the request was in
307
298
  // flight stay in the draft instead of being silently discarded.
308
299
  setDraft(previous => {
@@ -372,8 +363,6 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
372
363
  setLoad({ phase: 'ready', panel: result.panel })
373
364
  setStatus(result.panel.status)
374
365
  setDraft({})
375
- setCleanupResult(null)
376
- setCleanupError(null)
377
366
  setNotice({ kind: 'ok', text: PANEL_COPY.cleared })
378
367
  } catch (error) {
379
368
  setNotice({ kind: 'error', text: messageOf(error) })
@@ -417,40 +406,6 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
417
406
  }
418
407
  }, [refreshStatus, running])
419
408
 
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
409
  /** Replace the embedded access snapshot after a whitelist mutation. */
455
410
  const applyAccess = useCallback((access: AccessWire): void => {
456
411
  setLoad(previous => previous.phase === 'ready'
@@ -498,12 +453,12 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
498
453
  }
499
454
  }, [applyAccess])
500
455
 
501
- const onAddAccessUser = useCallback(async (openId: string, name: string): Promise<boolean> => {
456
+ const onAddAccessUser = useCallback(async (userId: string, name: string): Promise<boolean> => {
502
457
  setBusy('access')
503
458
  setAccessError(null)
504
459
  setAccessNotice(null)
505
460
  try {
506
- const result = await addAccessUser(openId, name)
461
+ const result = await addAccessUser(userId, name)
507
462
  if (!result.ok) {
508
463
  setAccessError(result.error)
509
464
  return false
@@ -519,12 +474,12 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
519
474
  }
520
475
  }, [applyAccess])
521
476
 
522
- const onRemoveAccessUser = useCallback(async (openId: string): Promise<void> => {
477
+ const onRemoveAccessUser = useCallback(async (userId: string): Promise<void> => {
523
478
  setBusy('access')
524
479
  setAccessError(null)
525
480
  setAccessNotice(null)
526
481
  try {
527
- const result = await removeAccessUser(openId)
482
+ const result = await removeAccessUser(userId)
528
483
  if (!result.ok) {
529
484
  setAccessError(result.error)
530
485
  return
@@ -729,13 +684,7 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
729
684
  {activeTab === 'model' ? <ModelTab form={form} catalogBusy={catalogBusy} /> : null}
730
685
  {activeTab === 'workspace'
731
686
  ? (
732
- <WorkspaceTab
733
- form={form}
734
- cleanup={form.panel.cleanup}
735
- cleanupResult={cleanupResult}
736
- cleanupError={cleanupError}
737
- onRunCleanup={() => { void onRunCleanup() }}
738
- />
687
+ <WorkspaceTab form={form} />
739
688
  )
740
689
  : null}
741
690
  {activeTab === 'access'
@@ -747,7 +696,7 @@ export function FeishuChannelsSection(_props: FeishuChannelsSectionProps): JSX.E
747
696
  onAddGroup={onAddAccessGroup}
748
697
  onRemoveGroup={chatId => { void onRemoveAccessGroup(chatId) }}
749
698
  onAddUser={onAddAccessUser}
750
- onRemoveUser={openId => { void onRemoveAccessUser(openId) }}
699
+ onRemoveUser={userId => { void onRemoveAccessUser(userId) }}
751
700
  onResolveRequest={(token, decision) => { void onResolveAccessRequest(token, decision) }}
752
701
  />
753
702
  )
@@ -13,7 +13,6 @@ import { FIELD_GROUP_LABELS, PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings
13
13
  import type {
14
14
  AccessWire,
15
15
  ChannelPhaseWire,
16
- CleanupPreviewWire,
17
16
  ConfigValue,
18
17
  CredentialField,
19
18
  EditableFieldWire,
@@ -24,6 +23,15 @@ import type {
24
23
  PanelTabId,
25
24
  PanelWire,
26
25
  } from '../../settings/contract.ts'
26
+ import {
27
+ listIsFull,
28
+ listItems,
29
+ listRows,
30
+ stringListDraftValue,
31
+ withBlankListEntry,
32
+ withListEntry,
33
+ withoutListEntry,
34
+ } from '../string-list.ts'
27
35
 
28
36
  /** Initial panel load: one request, retried from the error card. */
29
37
  export type LoadState =
@@ -46,7 +54,6 @@ export type BusyAction =
46
54
  | 'reconnect'
47
55
  | 'credentials'
48
56
  | 'credentials-clear'
49
- | 'cleanup'
50
57
  | 'access'
51
58
 
52
59
  /** Tone of a small badge; maps onto `dsh-channels-cfg__badge--*` classes. */
@@ -56,20 +63,6 @@ export function messageOf(error: unknown): string {
56
63
  return error instanceof Error ? error.message : String(error)
57
64
  }
58
65
 
59
- /** Human-readable byte size for the cleanup preview/result (base-2 units). */
60
- export function formatBytes(bytes: number): string {
61
- if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
62
- const units = ['B', 'KB', 'MB', 'GB', 'TB']
63
- let value = bytes
64
- let unit = 0
65
- while (value >= 1024 && unit < units.length - 1) {
66
- value /= 1024
67
- unit += 1
68
- }
69
- const digits = unit === 0 || value >= 10 ? 0 : 1
70
- return `${value.toFixed(digits)} ${units[unit] ?? 'B'}`
71
- }
72
-
73
66
  /** Compact duration for the connection card. */
74
67
  export function formatUptime(ms: number | undefined): string {
75
68
  if (ms === undefined || !Number.isFinite(ms)) return PANEL_COPY.unknownValue
@@ -142,6 +135,12 @@ export function validateDraft(
142
135
  values[field.key] = raw === true
143
136
  continue
144
137
  }
138
+ if (field.kind === 'string-list') {
139
+ const list = stringListDraftValue(field, raw)
140
+ if (list.ok) values[field.key] = list.value
141
+ else issues.push(...list.issues)
142
+ continue
143
+ }
145
144
  values[field.key] = String(raw ?? '')
146
145
  }
147
146
  return { values, issues }
@@ -252,6 +251,73 @@ function DirectoryTextInput(props: {
252
251
  )
253
252
  }
254
253
 
254
+ /**
255
+ * `string-list` control: one single-line input per entry, each with a remove
256
+ * button, and an add button below (disabled at `maxItems` inputs). An empty
257
+ * list renders one blank input so the first entry can be typed directly.
258
+ * Issues carrying an `index` mark that input and show the reason under it.
259
+ */
260
+ function StringListInput(props: {
261
+ field: EditableFieldWire
262
+ value: ConfigValue | undefined
263
+ issues: readonly FieldIssueWire[]
264
+ disabled: boolean
265
+ onChange: (key: EditableKey, value: ConfigValue) => void
266
+ }): JSX.Element {
267
+ const { field, value, issues, disabled, onChange } = props
268
+ const empty = listItems(value).length === 0
269
+ const rows = listRows(value)
270
+ return (
271
+ <div className="dsh-channels-cfg__list">
272
+ {rows.map((item, index) => {
273
+ const issue = issues.find(entry => entry.index === index)
274
+ const position = PANEL_COPY.listEntry.replace('{n}', String(index + 1))
275
+ // Entries have no identity beyond their position, so the index is the key.
276
+ return (
277
+ <div className="dsh-channels-cfg__listItem" key={index}>
278
+ <div className="dsh-channels-cfg__inputRow">
279
+ <input
280
+ type="text"
281
+ className={`dsh-channels-cfg__input${issue === undefined ? '' : ' dsh-channels-cfg__input--invalid'}`}
282
+ value={item}
283
+ placeholder={field.placeholder}
284
+ aria-label={`${field.label} ${position}`}
285
+ aria-invalid={issue === undefined ? undefined : true}
286
+ disabled={disabled}
287
+ onChange={event => onChange(field.key, withListEntry(rows, index, event.target.value))}
288
+ />
289
+ {empty
290
+ ? null
291
+ : (
292
+ <button
293
+ type="button"
294
+ className="dsh-channels-cfg__btn dsh-channels-cfg__btn--listRemove"
295
+ aria-label={`${PANEL_COPY.listRemove} ${position}`}
296
+ disabled={disabled}
297
+ onClick={() => onChange(field.key, withoutListEntry(rows, index))}
298
+ >
299
+ {PANEL_COPY.listRemove}
300
+ </button>
301
+ )}
302
+ </div>
303
+ {issue === undefined ? null : <span className="dsh-channels-cfg__fieldError">{issue.message}</span>}
304
+ </div>
305
+ )
306
+ })}
307
+ <div>
308
+ <button
309
+ type="button"
310
+ className="dsh-channels-cfg__btn"
311
+ disabled={disabled || listIsFull(field, rows)}
312
+ onClick={() => onChange(field.key, withBlankListEntry(rows))}
313
+ >
314
+ {PANEL_COPY.listAdd}
315
+ </button>
316
+ </div>
317
+ </div>
318
+ )
319
+ }
320
+
255
321
  /** One generic form control driven by the Host field descriptor. */
256
322
  export function FieldRow(props: {
257
323
  field: EditableFieldWire
@@ -263,7 +329,9 @@ export function FieldRow(props: {
263
329
  onPickDirectory?: () => Promise<void>
264
330
  }): JSX.Element {
265
331
  const { field, value, issues, disabled = false, onChange, onPickDirectory } = props
266
- const issue = issues.find(item => item.key === field.key)
332
+ // Entry issues (`index` set) are rendered next to their input by StringListInput.
333
+ const issue = issues.find(item => item.key === field.key && item.index === undefined)
334
+ const entryIssues = issues.filter(item => item.key === field.key && item.index !== undefined)
267
335
  const inputClass = `dsh-channels-cfg__input${issue === undefined ? '' : ' dsh-channels-cfg__input--invalid'}`
268
336
  const fieldClass = `dsh-channels-cfg__field${field.wide === true ? ' dsh-channels-cfg__field--wide' : ''}`
269
337
  return (
@@ -343,6 +411,17 @@ export function FieldRow(props: {
343
411
  />
344
412
  ))
345
413
  : null}
414
+ {field.kind === 'string-list'
415
+ ? (
416
+ <StringListInput
417
+ field={field}
418
+ value={value}
419
+ issues={entryIssues}
420
+ disabled={disabled}
421
+ onChange={onChange}
422
+ />
423
+ )
424
+ : null}
346
425
  {field.help === undefined ? null : <span className="dsh-channels-cfg__help">{field.help}</span>}
347
426
  {issue === undefined ? null : <span className="dsh-channels-cfg__fieldError">{issue.message}</span>}
348
427
  </div>
@@ -479,10 +558,3 @@ export function connectionStateLabel(state: string | undefined): { text: string;
479
558
  default: return { text: state ?? PANEL_COPY.unknownValue, tone: 'muted' }
480
559
  }
481
560
  }
482
-
483
- /** Retention label, reusing the Host field options so the copy stays on the Host. */
484
- export function retentionLabel(panel: PanelWire, cleanup: CleanupPreviewWire): string {
485
- const field = panel.config.fields.find(item => item.key === 'workspaceRetention')
486
- return field?.options?.find(option => option.value === cleanup.retention)?.label
487
- ?? (cleanup.retention === 'off' ? PANEL_COPY.cleanupRetentionOff : cleanup.retention)
488
- }
@@ -1,40 +1,24 @@
1
1
  /**
2
- * 「工作区」tab:工作区根目录 / 过期保留策略 / 自动清理字段 + 目录清理卡。
3
- * 表单保存栏在字段卡正下方,清理卡(独立动作,不走 draft)在其后。
2
+ * 「工作区」tab:工作区根目录字段与保存栏。
4
3
  *
5
4
  * @module dsh-channels/client/panel/workspace-tab
6
5
  */
7
6
 
8
7
  import { PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
9
- import type { CleanupPreviewWire, CleanupRunWire } from '../../settings/contract.ts'
10
- import { CleanupCard } from './cleanup-card.tsx'
11
8
  import { FieldsCard, SaveBar, TabHelp } from './shared.tsx'
12
9
  import type { FormView } from './shared.tsx'
13
10
 
14
11
  export interface WorkspaceTabProps {
15
12
  form: FormView
16
- cleanup: CleanupPreviewWire
17
- cleanupResult: CleanupRunWire | null
18
- cleanupError: string | null
19
- onRunCleanup: () => void
20
13
  }
21
14
 
22
15
  export function WorkspaceTab(props: WorkspaceTabProps): JSX.Element {
23
- const { form, cleanup, cleanupResult, cleanupError, onRunCleanup } = props
16
+ const { form } = props
24
17
  return (
25
18
  <div className="dsh-channels-cfg__tabBody">
26
19
  <TabHelp text={PANEL_COPY.workspaceTabHelp} />
27
20
  <FieldsCard form={form} groups={TAB_FIELD_GROUPS.workspace} />
28
21
  <SaveBar form={form} tab="workspace" />
29
- <CleanupCard
30
- panel={form.panel}
31
- cleanup={cleanup}
32
- busy={form.busy}
33
- locked={form.locked}
34
- result={cleanupResult}
35
- error={cleanupError}
36
- onRun={onRunCleanup}
37
- />
38
22
  </div>
39
23
  )
40
24
  }