@modesto_ma/dsh-channels 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.
@@ -0,0 +1,23 @@
1
+ /**
2
+ * 「模型」tab:模型服务商 / 模型 id / 推理档位 / Agent 预设四个选择器。
3
+ * 选项来自 Host 实时目录(页面外壳负责 provider→model→effort 级联刷新),
4
+ * 目录读取中时在表单上方提示。
5
+ *
6
+ * @module dsh-channels/client/panel/model-tab
7
+ */
8
+
9
+ import { PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
10
+ import { FieldsCard, SaveBar, TabHelp } from './shared.tsx'
11
+ import type { FormView } from './shared.tsx'
12
+
13
+ export function ModelTab(props: { form: FormView; catalogBusy: boolean }): JSX.Element {
14
+ const { form, catalogBusy } = props
15
+ return (
16
+ <div className="dsh-channels-cfg__tabBody">
17
+ <TabHelp text={PANEL_COPY.modelTabHelp} />
18
+ {catalogBusy ? <span className="dsh-channels-cfg__help">{PANEL_COPY.catalogLoading}</span> : null}
19
+ <FieldsCard form={form} groups={TAB_FIELD_GROUPS.model} />
20
+ <SaveBar form={form} tab="model" />
21
+ </div>
22
+ )
23
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * 「会话与行为」tab:行为 + 内容与附件两组字段(私聊/群聊触发方式、
3
+ * 首次引导、权限切换、附件接收)。表单字段完全由 Host 描述驱动。
4
+ *
5
+ * @module dsh-channels/client/panel/sessions-tab
6
+ */
7
+
8
+ import { PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
9
+ import { FieldsCard, SaveBar, TabHelp } from './shared.tsx'
10
+ import type { FormView } from './shared.tsx'
11
+
12
+ export function SessionsTab(props: { form: FormView }): JSX.Element {
13
+ const { form } = props
14
+ return (
15
+ <div className="dsh-channels-cfg__tabBody">
16
+ <TabHelp text={PANEL_COPY.sessionsTabHelp} />
17
+ <FieldsCard form={form} groups={TAB_FIELD_GROUPS.sessions} />
18
+ <SaveBar form={form} tab="sessions" />
19
+ </div>
20
+ )
21
+ }
@@ -0,0 +1,488 @@
1
+ /**
2
+ * 设置面板共享层:页面状态类型、Host 字段渲染原语(FieldRow / FieldsCard /
3
+ * SaveBar)、生命周期徽标与纯格式化工具。
4
+ *
5
+ * 这一层不发请求、不持有页面状态,只把 Host 下发的 wire 数据渲染成控件;
6
+ * 页面外壳在 `index.tsx`,各 tab 只组合这些原语。
7
+ *
8
+ * @module dsh-channels/client/panel/shared
9
+ */
10
+
11
+ import { useState } from 'react'
12
+ import { FIELD_GROUP_LABELS, PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
13
+ import type {
14
+ AccessWire,
15
+ ChannelPhaseWire,
16
+ CleanupPreviewWire,
17
+ ConfigValue,
18
+ CredentialField,
19
+ EditableFieldWire,
20
+ EditableKey,
21
+ EditableValues,
22
+ FieldGroup,
23
+ FieldIssueWire,
24
+ PanelTabId,
25
+ PanelWire,
26
+ } from '../../settings/contract.ts'
27
+
28
+ /** Initial panel load: one request, retried from the error card. */
29
+ export type LoadState =
30
+ | { phase: 'loading' }
31
+ | { phase: 'error'; message: string }
32
+ | { phase: 'ready'; panel: PanelWire }
33
+
34
+ /** One transient banner above the tab body (save/credentials/reconnect/catalog). */
35
+ export interface Notice {
36
+ kind: 'ok' | 'error'
37
+ text: string
38
+ /** Per-field validation failures rendered inline by FieldRow. */
39
+ issues?: FieldIssueWire[]
40
+ }
41
+
42
+ /** One in-flight panel mutation (only one action runs at a time). */
43
+ export type BusyAction =
44
+ | 'save'
45
+ | 'clear'
46
+ | 'reconnect'
47
+ | 'credentials'
48
+ | 'credentials-clear'
49
+ | 'cleanup'
50
+ | 'access'
51
+
52
+ /** Tone of a small badge; maps onto `dsh-channels-cfg__badge--*` classes. */
53
+ export type BadgeTone = 'ok' | 'warn' | 'bad' | 'muted'
54
+
55
+ export function messageOf(error: unknown): string {
56
+ return error instanceof Error ? error.message : String(error)
57
+ }
58
+
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
+ /** Compact duration for the connection card. */
74
+ export function formatUptime(ms: number | undefined): string {
75
+ if (ms === undefined || !Number.isFinite(ms)) return PANEL_COPY.unknownValue
76
+ const totalSeconds = Math.max(0, Math.floor(ms / 1_000))
77
+ const days = Math.floor(totalSeconds / 86_400)
78
+ const hours = Math.floor((totalSeconds % 86_400) / 3_600)
79
+ const minutes = Math.floor((totalSeconds % 3_600) / 60)
80
+ if (days > 0) return `${days} 天 ${hours} 小时`
81
+ if (hours > 0) return `${hours} 小时 ${minutes} 分`
82
+ if (minutes > 0) return `${minutes} 分 ${totalSeconds % 60} 秒`
83
+ return `${totalSeconds} 秒`
84
+ }
85
+
86
+ /** Effective value for one key (falling back to the profile base). */
87
+ export function effectiveValue(panel: PanelWire, key: EditableKey): ConfigValue | undefined {
88
+ return panel.config.effective[key] ?? panel.config.base[key]
89
+ }
90
+
91
+ /** Host label for one editable key; the raw key is the technical fallback. */
92
+ export function fieldLabel(fields: readonly EditableFieldWire[], key: EditableKey): string {
93
+ return fields.find(field => field.key === key)?.label ?? key
94
+ }
95
+
96
+ /** Effective access-mode label, reusing the Host field options for consistency. */
97
+ export function accessModeLabel(panel: PanelWire, mode: AccessWire['mode']): string {
98
+ const field = panel.config.fields.find(item => item.key === 'accessMode')
99
+ return field?.options?.find(option => option.value === mode)?.label ?? mode
100
+ }
101
+
102
+ /** Numeric draft kept as the raw input string; parsed on save. */
103
+ function numericDraft(field: EditableFieldWire, raw: ConfigValue | undefined): number | undefined {
104
+ if (typeof raw === 'number') return raw
105
+ if (typeof raw !== 'string' || raw.trim() === '') return undefined
106
+ const parsed = Number(raw)
107
+ return Number.isFinite(parsed) ? parsed : undefined
108
+ }
109
+
110
+ /**
111
+ * Turn the dirty draft into the wire payload, keeping the Host-descriptor
112
+ * bounds check client-side. Field messages stay technical (the Host re-validates
113
+ * every value on save).
114
+ */
115
+ export function validateDraft(
116
+ panel: PanelWire,
117
+ draft: EditableValues,
118
+ ): { values: EditableValues; issues: FieldIssueWire[] } {
119
+ const values: EditableValues = {}
120
+ const issues: FieldIssueWire[] = []
121
+ for (const field of panel.config.fields) {
122
+ if (!(field.key in draft)) continue
123
+ const raw = draft[field.key]
124
+ if (field.kind === 'number') {
125
+ const parsed = numericDraft(field, raw)
126
+ if (parsed === undefined) {
127
+ issues.push({ key: field.key, message: '请输入数字' })
128
+ continue
129
+ }
130
+ if (field.min !== undefined && parsed < field.min) {
131
+ issues.push({ key: field.key, message: `不得小于 ${field.min}` })
132
+ continue
133
+ }
134
+ if (field.max !== undefined && parsed > field.max) {
135
+ issues.push({ key: field.key, message: `不得大于 ${field.max}` })
136
+ continue
137
+ }
138
+ values[field.key] = parsed
139
+ continue
140
+ }
141
+ if (field.kind === 'boolean') {
142
+ values[field.key] = raw === true
143
+ continue
144
+ }
145
+ values[field.key] = String(raw ?? '')
146
+ }
147
+ return { values, issues }
148
+ }
149
+
150
+ /** Header badge for the Host-derived lifecycle phase. */
151
+ export function phaseBadge(phase: ChannelPhaseWire): { text: string; tone: BadgeTone } {
152
+ switch (phase) {
153
+ case 'connected': return { text: PANEL_COPY.phaseConnected, tone: 'ok' }
154
+ case 'connecting': return { text: PANEL_COPY.phaseConnecting, tone: 'warn' }
155
+ case 'error': return { text: PANEL_COPY.phaseError, tone: 'bad' }
156
+ case 'idle': return { text: PANEL_COPY.phaseIdle, tone: 'muted' }
157
+ default: return { text: PANEL_COPY.phaseUnconfigured, tone: 'muted' }
158
+ }
159
+ }
160
+
161
+ /** One-line explanation shown under the page title for the current phase. */
162
+ export function phaseSummary(phase: ChannelPhaseWire): string {
163
+ switch (phase) {
164
+ case 'connected': return PANEL_COPY.statusSummaryConnected
165
+ case 'connecting': return PANEL_COPY.statusSummaryConnecting
166
+ case 'error': return PANEL_COPY.statusSummaryError
167
+ case 'idle': return PANEL_COPY.statusSummaryIdle
168
+ default: return PANEL_COPY.statusSummaryUnconfigured
169
+ }
170
+ }
171
+
172
+ /** Credential editor data owned by the page shell, rendered by the connection tab. */
173
+ export interface CredentialsView {
174
+ appId: string
175
+ appSecret: string
176
+ issues: FieldIssueWire[]
177
+ busy: BusyAction | null
178
+ /** Host-side lifecycle busy (connecting/restarting): block writes. */
179
+ locked: boolean
180
+ onAppIdChange: (value: string) => void
181
+ onAppSecretChange: (value: string) => void
182
+ onSave: () => void
183
+ onClear: (field: CredentialField) => void
184
+ }
185
+
186
+ /** Everything a field-bearing tab needs from the page shell's single draft. */
187
+ export interface FormView {
188
+ panel: PanelWire
189
+ /** Host descriptors after the live selector catalog has been layered on. */
190
+ fields: readonly EditableFieldWire[]
191
+ draft: EditableValues
192
+ issues: FieldIssueWire[]
193
+ busy: BusyAction | null
194
+ /** Host-side lifecycle busy (connecting/restarting): block writes. */
195
+ locked: boolean
196
+ dirtyCount: number
197
+ onChange: (key: EditableKey, value: ConfigValue) => void
198
+ onSave: () => void
199
+ /** Drop only the draft keys owned by one tab. */
200
+ onDiscardTab: (tab: PanelTabId) => void
201
+ /**
202
+ * Open the Host's native directory chooser and write the picked absolute
203
+ * path into the draft (not saved until「保存更改」). Absent when the Host
204
+ * cannot offer a native chooser.
205
+ */
206
+ onPickDirectory?: (key: EditableKey) => Promise<void>
207
+ }
208
+
209
+ /**
210
+ * Text input paired with the host-native「选择目录…」button. The button owns
211
+ * its own busy state: the OS dialog can stay open for a long time, so the
212
+ * rest of the form stays usable while the operator browses.
213
+ */
214
+ function DirectoryTextInput(props: {
215
+ field: EditableFieldWire
216
+ value: ConfigValue | undefined
217
+ inputClass: string
218
+ disabled: boolean
219
+ onChange: (key: EditableKey, value: ConfigValue) => void
220
+ onPick: () => Promise<void>
221
+ }): JSX.Element {
222
+ const { field, value, inputClass, disabled, onChange, onPick } = props
223
+ const [picking, setPicking] = useState(false)
224
+ const pick = async (): Promise<void> => {
225
+ setPicking(true)
226
+ try {
227
+ await onPick()
228
+ } finally {
229
+ setPicking(false)
230
+ }
231
+ }
232
+ return (
233
+ <div className="dsh-channels-cfg__inputRow">
234
+ <input
235
+ type="text"
236
+ className={inputClass}
237
+ value={String(value ?? '')}
238
+ placeholder={field.placeholder}
239
+ aria-label={field.label}
240
+ disabled={disabled || picking}
241
+ onChange={event => onChange(field.key, event.target.value)}
242
+ />
243
+ <button
244
+ type="button"
245
+ className="dsh-channels-cfg__btn dsh-channels-cfg__btn--browse"
246
+ disabled={disabled || picking}
247
+ onClick={() => { void pick() }}
248
+ >
249
+ {picking ? PANEL_COPY.browsingDirectory : PANEL_COPY.browseDirectory}
250
+ </button>
251
+ </div>
252
+ )
253
+ }
254
+
255
+ /** One generic form control driven by the Host field descriptor. */
256
+ export function FieldRow(props: {
257
+ field: EditableFieldWire
258
+ value: ConfigValue | undefined
259
+ issues: FieldIssueWire[]
260
+ disabled?: boolean
261
+ onChange: (key: EditableKey, value: ConfigValue) => void
262
+ /** Present only for `field.browse === 'directory'` rows the page can serve. */
263
+ onPickDirectory?: () => Promise<void>
264
+ }): JSX.Element {
265
+ const { field, value, issues, disabled = false, onChange, onPickDirectory } = props
266
+ const issue = issues.find(item => item.key === field.key)
267
+ const inputClass = `dsh-channels-cfg__input${issue === undefined ? '' : ' dsh-channels-cfg__input--invalid'}`
268
+ const fieldClass = `dsh-channels-cfg__field${field.wide === true ? ' dsh-channels-cfg__field--wide' : ''}`
269
+ return (
270
+ <div className={fieldClass}>
271
+ <div className="dsh-channels-cfg__labelRow">
272
+ <span className="dsh-channels-cfg__label">{field.label}</span>
273
+ {field.restartRequired
274
+ ? <span className="dsh-channels-cfg__tag dsh-channels-cfg__tag--restart">{PANEL_COPY.restartTag}</span>
275
+ : null}
276
+ </div>
277
+ {field.kind === 'boolean'
278
+ ? (
279
+ <label className="dsh-channels-cfg__check">
280
+ <input
281
+ type="checkbox"
282
+ className="dsh-channels-cfg__checkbox"
283
+ checked={value === true}
284
+ aria-label={field.label}
285
+ disabled={disabled}
286
+ onChange={event => onChange(field.key, event.target.checked)}
287
+ />
288
+ <span>{value === true ? PANEL_COPY.enabled : PANEL_COPY.disabled}</span>
289
+ </label>
290
+ )
291
+ : null}
292
+ {field.kind === 'select'
293
+ ? (
294
+ <select
295
+ className={inputClass}
296
+ value={String(value ?? '')}
297
+ disabled={disabled || field.disabled === true}
298
+ aria-label={field.label}
299
+ onChange={event => onChange(field.key, event.target.value)}
300
+ >
301
+ {(field.options ?? []).map(option => (
302
+ <option key={option.value} value={option.value}>{option.label}</option>
303
+ ))}
304
+ </select>
305
+ )
306
+ : null}
307
+ {field.kind === 'number'
308
+ ? (
309
+ <input
310
+ type="number"
311
+ className={inputClass}
312
+ value={value === undefined || value === '' ? '' : String(value)}
313
+ min={field.min}
314
+ max={field.max}
315
+ step={field.step}
316
+ aria-label={field.label}
317
+ disabled={disabled}
318
+ onChange={event => onChange(field.key, event.target.value)}
319
+ />
320
+ )
321
+ : null}
322
+ {field.kind === 'text'
323
+ ? (field.browse === 'directory' && onPickDirectory !== undefined
324
+ ? (
325
+ <DirectoryTextInput
326
+ field={field}
327
+ value={value}
328
+ inputClass={inputClass}
329
+ disabled={disabled}
330
+ onChange={onChange}
331
+ onPick={onPickDirectory}
332
+ />
333
+ )
334
+ : (
335
+ <input
336
+ type="text"
337
+ className={inputClass}
338
+ value={String(value ?? '')}
339
+ placeholder={field.placeholder}
340
+ aria-label={field.label}
341
+ disabled={disabled}
342
+ onChange={event => onChange(field.key, event.target.value)}
343
+ />
344
+ ))
345
+ : null}
346
+ {field.help === undefined ? null : <span className="dsh-channels-cfg__help">{field.help}</span>}
347
+ {issue === undefined ? null : <span className="dsh-channels-cfg__fieldError">{issue.message}</span>}
348
+ </div>
349
+ )
350
+ }
351
+
352
+ /**
353
+ * One tab's editable fields, partitioned by the Host field groups that the tab
354
+ * owns (`TAB_FIELD_GROUPS`). Empty groups render nothing, so a Host that moves
355
+ * every key out of a group does not leave a stray heading behind.
356
+ */
357
+ export function FieldsCard(props: {
358
+ form: FormView
359
+ groups: readonly FieldGroup[]
360
+ }): JSX.Element | null {
361
+ const { form, groups } = props
362
+ const sections = groups
363
+ .map(group => ({
364
+ group,
365
+ label: FIELD_GROUP_LABELS[group],
366
+ fields: form.fields.filter(field => field.group === group),
367
+ }))
368
+ .filter(section => section.fields.length > 0)
369
+ if (sections.length === 0) return null
370
+ return (
371
+ <section className="dsh-channels-cfg__card">
372
+ {sections.map(section => (
373
+ <div className="dsh-channels-cfg__group" key={section.group}>
374
+ <div className="dsh-channels-cfg__labelRow">
375
+ <span className="dsh-channels-cfg__cardTitle">{section.label}</span>
376
+ </div>
377
+ <div className="dsh-channels-cfg__grid">
378
+ {section.fields.map((field) => {
379
+ const onPick = form.onPickDirectory
380
+ return (
381
+ <FieldRow
382
+ key={field.key}
383
+ field={field}
384
+ value={form.draft[field.key] ?? effectiveValue(form.panel, field.key)}
385
+ issues={form.issues}
386
+ disabled={form.locked}
387
+ onChange={form.onChange}
388
+ onPickDirectory={onPick === undefined ? undefined : () => onPick(field.key)}
389
+ />
390
+ )
391
+ })}
392
+ </div>
393
+ </div>
394
+ ))}
395
+ </section>
396
+ )
397
+ }
398
+
399
+ /** The unified per-tab action bar: save every dirty field or drop the draft. */
400
+ export function SaveBar(props: { form: FormView; tab: PanelTabId }): JSX.Element {
401
+ const { form, tab } = props
402
+ const groups = new Set<string>(TAB_FIELD_GROUPS[tab])
403
+ const pageDirty = form.fields.filter(field => groups.has(field.group) && field.key in form.draft).length
404
+ // Saving submits the whole draft, so any dirty key (any tab) enables it;
405
+ // discarding only clears this tab, so it follows this tab's own count.
406
+ const dirty = form.dirtyCount > 0
407
+ const disabled = !dirty || form.busy !== null || form.locked
408
+ return (
409
+ <div className="dsh-channels-cfg__footer">
410
+ <button
411
+ type="button"
412
+ className="dsh-channels-cfg__btn dsh-channels-cfg__btn--primary"
413
+ onClick={form.onSave}
414
+ disabled={disabled}
415
+ >
416
+ {form.busy === 'save' ? PANEL_COPY.saving : PANEL_COPY.save}
417
+ </button>
418
+ <button
419
+ type="button"
420
+ className="dsh-channels-cfg__btn"
421
+ onClick={() => form.onDiscardTab(tab)}
422
+ disabled={pageDirty === 0 || form.busy !== null || form.locked}
423
+ >
424
+ {PANEL_COPY.discardTab}
425
+ </button>
426
+ <span className="dsh-channels-cfg__spacer" />
427
+ {pageDirty > 0
428
+ ? (
429
+ <span className="dsh-channels-cfg__tag dsh-channels-cfg__tag--dirty">
430
+ {PANEL_COPY.dirtyCount} {pageDirty}
431
+ </span>
432
+ )
433
+ : dirty
434
+ ? (
435
+ <span className="dsh-channels-cfg__tag dsh-channels-cfg__tag--dirty">
436
+ {PANEL_COPY.dirtyCount} {form.dirtyCount}(其他标签页)
437
+ </span>
438
+ )
439
+ : null}
440
+ <span className="dsh-channels-cfg__help">
441
+ {dirty ? PANEL_COPY.saveHintDirty : PANEL_COPY.saveHintClean}
442
+ </span>
443
+ </div>
444
+ )
445
+ }
446
+
447
+ /** Muted help paragraph introducing one tab. */
448
+ export function TabHelp(props: { text: string }): JSX.Element {
449
+ return <p className="dsh-channels-cfg__tabHelp">{props.text}</p>
450
+ }
451
+
452
+ /** Transient page-level banner (save / credentials / reconnect / catalog). */
453
+ export function NoticeBar(props: { notice: Notice }): JSX.Element {
454
+ const { notice } = props
455
+ return (
456
+ <div
457
+ className={`dsh-channels-cfg__notice dsh-channels-cfg__notice--${notice.kind === 'ok' ? 'ok' : 'error'}`}
458
+ role="status"
459
+ aria-live="polite"
460
+ >
461
+ <span>{notice.text}</span>
462
+ </div>
463
+ )
464
+ }
465
+
466
+ /** Muted empty/blocked state (e.g. access lists before the channel connects). */
467
+ export function EmptyState(props: { text: string }): JSX.Element {
468
+ return <div className="dsh-channels-cfg__emptyState">{props.text}</div>
469
+ }
470
+
471
+ /** Human label for the official SDK's WebSocket lifecycle state. */
472
+ export function connectionStateLabel(state: string | undefined): { text: string; tone: BadgeTone } {
473
+ switch (state) {
474
+ case 'connected': return { text: PANEL_COPY.phaseConnected, tone: 'ok' }
475
+ case 'connecting': return { text: PANEL_COPY.phaseConnecting, tone: 'warn' }
476
+ case 'reconnecting': return { text: PANEL_COPY.phaseConnecting, tone: 'warn' }
477
+ case 'failed': return { text: PANEL_COPY.phaseError, tone: 'bad' }
478
+ case 'idle': return { text: PANEL_COPY.phaseIdle, tone: 'muted' }
479
+ default: return { text: state ?? PANEL_COPY.unknownValue, tone: 'muted' }
480
+ }
481
+ }
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
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * 「工作区」tab:工作区根目录 / 过期保留策略 / 自动清理字段 + 工作区清理卡。
3
+ * 表单保存栏在字段卡正下方,清理卡(独立动作,不走 draft)在其后。
4
+ *
5
+ * @module dsh-channels/client/panel/workspace-tab
6
+ */
7
+
8
+ 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
+ import { FieldsCard, SaveBar, TabHelp } from './shared.tsx'
12
+ import type { FormView } from './shared.tsx'
13
+
14
+ export interface WorkspaceTabProps {
15
+ form: FormView
16
+ cleanup: CleanupPreviewWire
17
+ cleanupResult: CleanupRunWire | null
18
+ cleanupError: string | null
19
+ onRunCleanup: () => void
20
+ }
21
+
22
+ export function WorkspaceTab(props: WorkspaceTabProps): JSX.Element {
23
+ const { form, cleanup, cleanupResult, cleanupError, onRunCleanup } = props
24
+ return (
25
+ <div className="dsh-channels-cfg__tabBody">
26
+ <TabHelp text={PANEL_COPY.workspaceTabHelp} />
27
+ <FieldsCard form={form} groups={TAB_FIELD_GROUPS.workspace} />
28
+ <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
+ </div>
39
+ )
40
+ }
@@ -10,14 +10,23 @@ const STYLE_ATTRIBUTE = 'data-dsh-channels-css'
10
10
 
11
11
  const CSS = `
12
12
  .dsh-channels-cfg{container-type:inline-size;display:flex;flex-direction:column;gap:12px;width:100%;max-width:720px;color:var(--dsw-alias-label-primary,#1f2328);font-size:13px;line-height:20px}
13
- .dsh-channels-cfg__head{display:flex;flex-direction:column;gap:4px}
13
+ .dsh-channels-cfg__panelHead{position:sticky;top:0;z-index:2;display:flex;flex-direction:column;gap:4px;padding:10px 0 8px;background:var(--dsw-alias-bg-base,#fff);border-bottom:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12))}
14
+ .dsh-channels-cfg__headRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
14
15
  .dsh-channels-cfg__title{margin:0;font-size:16px;font-weight:500;line-height:24px}
15
- .dsh-channels-cfg__desc{margin:0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:18px}
16
+ .dsh-channels-cfg__summary{margin:0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:18px}
17
+ .dsh-channels-cfg__tabs{display:flex;align-items:center;gap:2px;flex-wrap:wrap;border-bottom:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12))}
18
+ .dsh-channels-cfg__tab{display:inline-flex;align-items:center;gap:5px;min-height:32px;padding:0 10px;border:0;border-bottom:2px solid transparent;background:transparent;color:var(--dsw-alias-label-secondary,#5c6169);font:inherit;font-size:13px;cursor:pointer;white-space:nowrap}
19
+ .dsh-channels-cfg__tab:hover{color:var(--dsw-alias-label-primary,#1f2328)}
20
+ .dsh-channels-cfg__tab:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#0a67d3);outline-offset:-2px}
21
+ .dsh-channels-cfg__tab[aria-selected="true"]{color:var(--dsw-alias-state-business-primary,#0a67d3);border-bottom-color:currentColor;font-weight:500}
22
+ .dsh-channels-cfg__tabDot{width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-warning-primary,#a15c00);flex:none}
23
+ .dsh-channels-cfg__tabBody{display:flex;flex-direction:column;gap:12px}
24
+ .dsh-channels-cfg__tabHelp{margin:0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:18px}
16
25
  .dsh-channels-cfg__card{display:flex;flex-direction:column;gap:10px;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:12px}
17
26
  .dsh-channels-cfg__cardHead{display:flex;align-items:center;gap:8px;min-height:20px;flex-wrap:wrap}
18
27
  .dsh-channels-cfg__cardTitle{font-size:13px;font-weight:500}
19
28
  .dsh-channels-cfg__cardMeta{margin-left:auto;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px}
20
- .dsh-channels-cfg__statusRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
29
+ .dsh-channels-cfg__group{display:flex;flex-direction:column;gap:8px}
21
30
  .dsh-channels-cfg__badge{display:inline-flex;align-items:center;gap:5px;padding:1px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));font-size:12px;line-height:18px;white-space:nowrap}
22
31
  .dsh-channels-cfg__dot{width:7px;height:7px;border-radius:50%;background:currentColor;flex:none}
23
32
  .dsh-channels-cfg__badge--ok{color:var(--dsw-alias-state-business-primary,#0a7d33);border-color:currentColor}
@@ -42,29 +51,37 @@ const CSS = `
42
51
  .dsh-channels-cfg__labelRow{display:flex;align-items:center;gap:6px;flex-wrap:wrap;min-width:0}
43
52
  .dsh-channels-cfg__label{font-size:12px;color:var(--dsw-alias-label-secondary,#5c6169)}
44
53
  .dsh-channels-cfg__tag{display:inline-flex;align-items:center;padding:0 5px;border-radius:4px;border:1px dashed currentColor;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,#8a8f98);white-space:nowrap}
45
- .dsh-channels-cfg__tag--override{color:var(--dsw-alias-state-business-primary,#0a67d3);border-style:solid}
46
54
  .dsh-channels-cfg__tag--restart{color:var(--dsw-alias-state-warning-primary,#a15c00)}
55
+ .dsh-channels-cfg__tag--dirty{color:var(--dsw-alias-state-warning-primary,#a15c00);border-style:solid}
47
56
  .dsh-channels-cfg__help{color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:11px;line-height:16px}
48
57
  .dsh-channels-cfg__reasonBad{color:var(--dsw-alias-state-danger-primary,#c62828)}
49
58
  .dsh-channels-cfg__input{box-sizing:border-box;width:100%;min-height:28px;padding:3px 10px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:8px;background:var(--dsw-alias-bg-layer-1,transparent);color:inherit;font:inherit;font-size:13px;outline:none}
59
+ .dsh-channels-cfg__inputRow{display:flex;align-items:center;gap:8px;min-width:0}
60
+ .dsh-channels-cfg__inputRow .dsh-channels-cfg__input{flex:1 1 auto;width:auto;min-width:0}
61
+ .dsh-channels-cfg__btn--browse{flex:none}
50
62
  .dsh-channels-cfg__input:focus{border-color:var(--dsw-alias-state-business-primary,#0a67d3)}
51
63
  .dsh-channels-cfg__input--invalid{border-color:var(--dsw-alias-state-danger-primary,#c62828)}
52
64
  select.dsh-channels-cfg__input{appearance:auto;cursor:pointer}
53
65
  .dsh-channels-cfg__check{display:inline-flex;align-items:center;gap:6px;min-height:28px;cursor:pointer;font-size:13px}
54
66
  .dsh-channels-cfg__checkbox{width:15px;height:15px;margin:0;accent-color:var(--dsw-alias-state-business-primary,#0a67d3);cursor:pointer}
55
67
  .dsh-channels-cfg__fieldError{color:var(--dsw-alias-state-danger-primary,#c62828);font-size:11px;line-height:16px}
56
- .dsh-channels-cfg__notice{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border-radius:8px;font-size:12px;line-height:18px}
68
+ .dsh-channels-cfg__notice{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border-radius:8px;font-size:12px;line-height:18px;overflow-wrap:anywhere}
57
69
  .dsh-channels-cfg__notice--ok{color:var(--dsw-alias-state-business-primary,#0a7d33);background:color-mix(in srgb,currentColor 8%,transparent)}
58
70
  .dsh-channels-cfg__notice--error{color:var(--dsw-alias-state-danger-primary,#c62828);background:color-mix(in srgb,currentColor 8%,transparent)}
59
71
  .dsh-channels-cfg__banner{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 10px;border-radius:8px;border:1px dashed var(--dsw-alias-state-warning-primary,#a15c00);color:var(--dsw-alias-state-warning-primary,#a15c00);font-size:12px;line-height:18px}
60
72
  .dsh-channels-cfg__footer{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding-top:2px}
61
73
  .dsh-channels-cfg__spacer{flex:1}
74
+ .dsh-channels-cfg__steps{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
75
+ .dsh-channels-cfg__step{display:flex;align-items:flex-start;gap:10px}
76
+ .dsh-channels-cfg__stepIndex{display:inline-flex;align-items:center;justify-content:center;flex:none;width:20px;height:20px;border-radius:50%;border:1px solid currentColor;color:var(--dsw-alias-state-business-primary,#0a67d3);font-size:12px;line-height:18px;font-variant-numeric:tabular-nums}
77
+ .dsh-channels-cfg__stepBody{display:flex;flex-direction:column;gap:2px;min-width:0}
78
+ .dsh-channels-cfg__stepTitle{font-size:13px;font-weight:500}
79
+ .dsh-channels-cfg__emptyState{display:flex;align-items:center;gap:8px;padding:10px 12px;border:1px dashed var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:8px;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:18px}
62
80
  .dsh-channels-cfg__readOnly{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 20px}
63
81
  .dsh-channels-cfg__kv{display:flex;flex-direction:column;gap:2px;min-width:0}
64
82
  .dsh-channels-cfg__kvKey{color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:12px;line-height:16px}
65
83
  .dsh-channels-cfg__kvValue{overflow-wrap:anywhere}
66
84
  .dsh-channels-cfg__mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px}
67
- .dsh-channels-cfg__creds{display:flex;flex-direction:column;gap:6px}
68
85
  .dsh-channels-cfg__cred{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
69
86
  .dsh-channels-cfg__cred .dsh-channels-cfg__input{flex:1 1 240px;min-width:0;width:auto}
70
87
  .dsh-channels-cfg__accessSection{display:flex;flex-direction:column;gap:6px}
@@ -81,6 +98,7 @@ select.dsh-channels-cfg__input{appearance:auto;cursor:pointer}
81
98
  @container (max-width:560px){
82
99
  .dsh-channels-cfg__facts{grid-template-columns:repeat(2,minmax(0,1fr))}
83
100
  .dsh-channels-cfg__grid,.dsh-channels-cfg__readOnly{grid-template-columns:minmax(0,1fr)}
101
+ .dsh-channels-cfg__tabs{flex-wrap:nowrap;overflow-x:auto}
84
102
  }
85
103
  `
86
104