@shendeguize/dsh-agent-sidecar 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -11
- package/lib/client.js +4708 -2932
- package/lib/client.js.map +1 -1
- package/package.json +3 -1
- package/src/client/analysis/AnalysisPanel.tsx +19 -27
- package/src/client/analysis/analysis.module.css +36 -97
- package/src/client/board/Board.tsx +255 -48
- package/src/client/board/board.module.css +113 -92
- package/src/client/board/logic.ts +99 -21
- package/src/client/board/project-view-logic.ts +27 -34
- package/src/client/board/project-view.module.css +46 -83
- package/src/client/board/project-view.tsx +50 -8
- package/src/client/board/strings.ts +70 -85
- package/src/client/commands.ts +30 -15
- package/src/client/controller.ts +27 -7
- package/src/client/detail/SessionDetail.tsx +234 -37
- package/src/client/detail/detail.module.css +100 -153
- package/src/client/detail/logic.ts +220 -29
- package/src/client/detail/strings.ts +66 -77
- package/src/client/detail-glue.ts +33 -0
- package/src/client/detail-view.module.css +43 -35
- package/src/client/detail-view.tsx +138 -118
- package/src/client/dsh-tools/LineageTree.tsx +22 -13
- package/src/client/dsh-tools/SearchPanel.tsx +18 -11
- package/src/client/dsh-tools/dsh-tools.module.css +53 -140
- package/src/client/dsh-tools/strings.ts +42 -77
- package/src/client/index.ts +285 -124
- package/src/client/inject/InjectPanel.tsx +81 -61
- package/src/client/inject/inject.module.css +97 -197
- package/src/client/inject/logic.ts +38 -0
- package/src/client/lifecycle/handoff.ts +83 -0
- package/src/client/locales/en.ts +91 -4
- package/src/client/locales/host.ts +131 -0
- package/src/client/locales/index.ts +196 -8
- package/src/client/locales/react.ts +10 -0
- package/src/client/locales/view.ts +38 -0
- package/src/client/locales/zh.ts +200 -125
- package/src/client/mount.tsx +75 -41
- package/src/client/navigation/CenterOverlay.tsx +40 -0
- package/src/client/navigation/center-overlay.module.css +51 -0
- package/src/client/navigation/center.ts +45 -0
- package/src/client/navigation/modal-isolation.ts +152 -0
- package/src/client/navigation/modal-surface-anchor.ts +72 -0
- package/src/client/navigation/sidebar-entry.module.css +73 -0
- package/src/client/navigation/sidebar-entry.ts +309 -0
- package/src/client/primitives/StaticPill.tsx +21 -0
- package/src/client/settings-card.module.css +35 -119
- package/src/client/settings-card.tsx +31 -153
- package/src/client/settings-fields.tsx +131 -0
- package/src/client/sidebar/SidebarTab.tsx +156 -0
- package/src/client/sidebar/model.ts +65 -0
- package/src/client/sidebar/sidebar-tab.module.css +118 -0
- package/src/client/sidebar-tab.tsx +46 -320
- package/src/client/theme/agsc.module.css +31 -0
- package/src/client/theme/parts.ts +56 -0
- package/src/client/ui-integration.ts +86 -0
- package/src/client/widget.tsx +42 -16
- package/src/client/inject/overlay.module.css +0 -22
|
@@ -30,10 +30,22 @@
|
|
|
30
30
|
* the sidecar card reads native next to first-party ones.
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
Button,
|
|
35
|
+
IconChevronDownOutline14,
|
|
36
|
+
Pill,
|
|
37
|
+
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
38
|
+
import { useState } from 'react'
|
|
34
39
|
import type { ReactNode } from 'react'
|
|
35
40
|
import { t as defaultT } from './locales/index.ts'
|
|
36
41
|
import type { SidecarLocaleKey } from './locales/index.ts'
|
|
42
|
+
import {
|
|
43
|
+
NumberField,
|
|
44
|
+
SelectField,
|
|
45
|
+
TextField,
|
|
46
|
+
ToggleField,
|
|
47
|
+
} from './settings-fields.tsx'
|
|
48
|
+
import { surfaceProps } from './theme/parts.ts'
|
|
37
49
|
import css from './settings-card.module.css'
|
|
38
50
|
|
|
39
51
|
/**
|
|
@@ -176,147 +188,6 @@ function Section(props: SectionProps): ReactNode {
|
|
|
176
188
|
)
|
|
177
189
|
}
|
|
178
190
|
|
|
179
|
-
interface SelectFieldProps {
|
|
180
|
-
label: string
|
|
181
|
-
hint: string
|
|
182
|
-
value: string
|
|
183
|
-
options: readonly { value: string, label: string }[]
|
|
184
|
-
disabled: boolean
|
|
185
|
-
onCommit: (value: string) => void
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function SelectField(props: SelectFieldProps): ReactNode {
|
|
189
|
-
const id = useId()
|
|
190
|
-
return (
|
|
191
|
-
<div className={css['field']}>
|
|
192
|
-
<label className={css['label']} htmlFor={id}>{props.label}</label>
|
|
193
|
-
<select
|
|
194
|
-
id={id}
|
|
195
|
-
className={css['select']}
|
|
196
|
-
value={props.value}
|
|
197
|
-
disabled={props.disabled}
|
|
198
|
-
onChange={(event) => { props.onCommit(event.target.value) }}
|
|
199
|
-
>
|
|
200
|
-
{props.options.map(option => (
|
|
201
|
-
<option key={option.value} value={option.value}>{option.label}</option>
|
|
202
|
-
))}
|
|
203
|
-
</select>
|
|
204
|
-
<p className={css['hint']}>{props.hint}</p>
|
|
205
|
-
</div>
|
|
206
|
-
)
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
interface ToggleFieldProps {
|
|
210
|
-
label: string
|
|
211
|
-
hint: string
|
|
212
|
-
checked: boolean
|
|
213
|
-
disabled: boolean
|
|
214
|
-
onCommit: (checked: boolean) => void
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function ToggleField(props: ToggleFieldProps): ReactNode {
|
|
218
|
-
return (
|
|
219
|
-
<div className={css['field']}>
|
|
220
|
-
<label className={css['toggleRow']}>
|
|
221
|
-
<input
|
|
222
|
-
type="checkbox"
|
|
223
|
-
checked={props.checked}
|
|
224
|
-
disabled={props.disabled}
|
|
225
|
-
onChange={(event) => { props.onCommit(event.target.checked) }}
|
|
226
|
-
/>
|
|
227
|
-
<span className={css['label']}>{props.label}</span>
|
|
228
|
-
</label>
|
|
229
|
-
<p className={css['hint']}>{props.hint}</p>
|
|
230
|
-
</div>
|
|
231
|
-
)
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
interface TextFieldProps {
|
|
235
|
-
label: string
|
|
236
|
-
hint: string
|
|
237
|
-
value: string
|
|
238
|
-
placeholder?: string
|
|
239
|
-
disabled: boolean
|
|
240
|
-
onCommit: (value: string) => void
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** Text field with a local draft so typing survives a non-echoing beat. */
|
|
244
|
-
function TextField(props: TextFieldProps): ReactNode {
|
|
245
|
-
const id = useId()
|
|
246
|
-
const [draft, setDraft] = useState(props.value)
|
|
247
|
-
useEffect(() => { setDraft(props.value) }, [props.value])
|
|
248
|
-
return (
|
|
249
|
-
<div className={css['field']}>
|
|
250
|
-
<label className={css['label']} htmlFor={id}>{props.label}</label>
|
|
251
|
-
<input
|
|
252
|
-
id={id}
|
|
253
|
-
className={css['input']}
|
|
254
|
-
type="text"
|
|
255
|
-
value={draft}
|
|
256
|
-
placeholder={props.placeholder ?? ''}
|
|
257
|
-
disabled={props.disabled}
|
|
258
|
-
onChange={(event) => {
|
|
259
|
-
setDraft(event.target.value)
|
|
260
|
-
props.onCommit(event.target.value)
|
|
261
|
-
}}
|
|
262
|
-
/>
|
|
263
|
-
<p className={css['hint']}>{props.hint}</p>
|
|
264
|
-
</div>
|
|
265
|
-
)
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
interface NumberFieldProps {
|
|
269
|
-
label: string
|
|
270
|
-
hint: string
|
|
271
|
-
/** Copy shown while the draft is not an acceptable integer. */
|
|
272
|
-
invalidHint: string
|
|
273
|
-
/** Schema lower bound (host Config schemastery `.min()`). */
|
|
274
|
-
min: number
|
|
275
|
-
value: number
|
|
276
|
-
disabled: boolean
|
|
277
|
-
onCommit: (value: number) => void
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* Integer field with a local draft: invalid intermediate text (empty,
|
|
282
|
-
* non-numeric, below the schema minimum) shows the invalid hint and commits
|
|
283
|
-
* nothing, so the staged value can never leave the schema's domain.
|
|
284
|
-
*/
|
|
285
|
-
function NumberField(props: NumberFieldProps): ReactNode {
|
|
286
|
-
const id = useId()
|
|
287
|
-
const [draft, setDraft] = useState(String(props.value))
|
|
288
|
-
const [invalid, setInvalid] = useState(false)
|
|
289
|
-
useEffect(() => {
|
|
290
|
-
setDraft(String(props.value))
|
|
291
|
-
setInvalid(false)
|
|
292
|
-
}, [props.value])
|
|
293
|
-
return (
|
|
294
|
-
<div className={css['field']}>
|
|
295
|
-
<label className={css['label']} htmlFor={id}>{props.label}</label>
|
|
296
|
-
<input
|
|
297
|
-
id={id}
|
|
298
|
-
className={invalid ? `${css['input']} ${css['inputInvalid']}` : css['input']}
|
|
299
|
-
type="text"
|
|
300
|
-
inputMode="numeric"
|
|
301
|
-
value={draft}
|
|
302
|
-
disabled={props.disabled}
|
|
303
|
-
{...invalid ? { 'aria-invalid': true } : {}}
|
|
304
|
-
onChange={(event) => {
|
|
305
|
-
const text = event.target.value
|
|
306
|
-
setDraft(text)
|
|
307
|
-
const parsed = Number(text)
|
|
308
|
-
const acceptable = text.trim() !== '' && Number.isInteger(parsed) && parsed >= props.min
|
|
309
|
-
setInvalid(!acceptable)
|
|
310
|
-
if (acceptable && parsed !== props.value) props.onCommit(parsed)
|
|
311
|
-
}}
|
|
312
|
-
/>
|
|
313
|
-
{invalid
|
|
314
|
-
? <p className={css['invalidHint']} role="alert">{props.invalidHint}</p>
|
|
315
|
-
: <p className={css['hint']}>{props.hint}</p>}
|
|
316
|
-
</div>
|
|
317
|
-
)
|
|
318
|
-
}
|
|
319
|
-
|
|
320
191
|
/**
|
|
321
192
|
* Render the Agent Sidecar settings card.
|
|
322
193
|
* @param props - staged values, form state, and the wiring callbacks.
|
|
@@ -334,9 +205,10 @@ export function SettingsCard(props: SettingsCardProps): ReactNode {
|
|
|
334
205
|
: props.daemon?.state === 'failed'
|
|
335
206
|
? t('settings.daemonFailedNote')
|
|
336
207
|
: undefined
|
|
208
|
+
const cardClassName = `${css['card']} ${open ? css['cardOpen'] : ''}`
|
|
337
209
|
|
|
338
210
|
return (
|
|
339
|
-
<li
|
|
211
|
+
<li {...surfaceProps('settings-card', cardClassName)}>
|
|
340
212
|
<button
|
|
341
213
|
type="button"
|
|
342
214
|
className={css['header']}
|
|
@@ -348,8 +220,10 @@ export function SettingsCard(props: SettingsCardProps): ReactNode {
|
|
|
348
220
|
<span className={css['name']}>{title}</span>
|
|
349
221
|
<span className={css['description']}>{t('settings.cardDescription')}</span>
|
|
350
222
|
</span>
|
|
351
|
-
{props.dirty ? <
|
|
352
|
-
<
|
|
223
|
+
{props.dirty ? <Pill className={css['pending']}>{t('settings.unsaved')}</Pill> : null}
|
|
224
|
+
<IconChevronDownOutline14
|
|
225
|
+
className={`${css['chevron']} ${open ? css['chevronOpen'] : ''}`}
|
|
226
|
+
/>
|
|
353
227
|
</button>
|
|
354
228
|
{open
|
|
355
229
|
? (
|
|
@@ -380,13 +254,15 @@ export function SettingsCard(props: SettingsCardProps): ReactNode {
|
|
|
380
254
|
: null}
|
|
381
255
|
{props.daemon.state === 'failed' && props.onDaemonRetry !== undefined
|
|
382
256
|
? (
|
|
383
|
-
<
|
|
257
|
+
<Button
|
|
384
258
|
type="button"
|
|
259
|
+
size="sm"
|
|
260
|
+
variant="outline"
|
|
385
261
|
className={css['retry']}
|
|
386
262
|
onClick={props.onDaemonRetry}
|
|
387
263
|
>
|
|
388
264
|
{t('settings.daemonRetry')}
|
|
389
|
-
</
|
|
265
|
+
</Button>
|
|
390
266
|
)
|
|
391
267
|
: null}
|
|
392
268
|
</div>
|
|
@@ -540,22 +416,24 @@ export function SettingsCard(props: SettingsCardProps): ReactNode {
|
|
|
540
416
|
{props.saveFailed === true
|
|
541
417
|
? <p className={css['failed']} role="status">{t('settings.saveFailed')}</p>
|
|
542
418
|
: <span className={css['spacer']} />}
|
|
543
|
-
<
|
|
419
|
+
<Button
|
|
544
420
|
type="button"
|
|
545
|
-
|
|
421
|
+
size="sm"
|
|
422
|
+
variant="outline"
|
|
546
423
|
disabled={!props.dirty || props.saving}
|
|
547
424
|
onClick={props.onDiscard}
|
|
548
425
|
>
|
|
549
426
|
{t('settings.discard')}
|
|
550
|
-
</
|
|
551
|
-
<
|
|
427
|
+
</Button>
|
|
428
|
+
<Button
|
|
552
429
|
type="button"
|
|
553
|
-
|
|
430
|
+
size="sm"
|
|
431
|
+
variant="primary"
|
|
554
432
|
disabled={!props.dirty || props.saving || !props.writable}
|
|
555
433
|
onClick={props.onSave}
|
|
556
434
|
>
|
|
557
435
|
{t(props.saving ? 'settings.saving' : 'settings.save')}
|
|
558
|
-
</
|
|
436
|
+
</Button>
|
|
559
437
|
</div>
|
|
560
438
|
</div>
|
|
561
439
|
)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Input } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
2
|
+
import { useEffect, useId, useState } from 'react'
|
|
3
|
+
import type { ReactElement } from 'react'
|
|
4
|
+
import css from './settings-card.module.css'
|
|
5
|
+
|
|
6
|
+
interface FieldCopy {
|
|
7
|
+
label: string
|
|
8
|
+
hint: string
|
|
9
|
+
disabled: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface SelectFieldProps extends FieldCopy {
|
|
13
|
+
value: string
|
|
14
|
+
options: readonly { value: string, label: string }[]
|
|
15
|
+
onCommit: (value: string) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function SelectField(props: SelectFieldProps): ReactElement {
|
|
19
|
+
const id = useId()
|
|
20
|
+
return (
|
|
21
|
+
<div className={css['field']}>
|
|
22
|
+
<label className={css['label']} htmlFor={id}>{props.label}</label>
|
|
23
|
+
<select
|
|
24
|
+
id={id}
|
|
25
|
+
className={css['select']}
|
|
26
|
+
value={props.value}
|
|
27
|
+
disabled={props.disabled}
|
|
28
|
+
onChange={(event) => { props.onCommit(event.target.value) }}
|
|
29
|
+
>
|
|
30
|
+
{props.options.map(option => (
|
|
31
|
+
<option key={option.value} value={option.value}>{option.label}</option>
|
|
32
|
+
))}
|
|
33
|
+
</select>
|
|
34
|
+
<p className={css['hint']}>{props.hint}</p>
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ToggleFieldProps extends FieldCopy {
|
|
40
|
+
checked: boolean
|
|
41
|
+
onCommit: (checked: boolean) => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function ToggleField(props: ToggleFieldProps): ReactElement {
|
|
45
|
+
return (
|
|
46
|
+
<div className={css['field']}>
|
|
47
|
+
<label className={css['toggleRow']}>
|
|
48
|
+
<input
|
|
49
|
+
type="checkbox"
|
|
50
|
+
checked={props.checked}
|
|
51
|
+
disabled={props.disabled}
|
|
52
|
+
onChange={(event) => { props.onCommit(event.target.checked) }}
|
|
53
|
+
/>
|
|
54
|
+
<span className={css['label']}>{props.label}</span>
|
|
55
|
+
</label>
|
|
56
|
+
<p className={css['hint']}>{props.hint}</p>
|
|
57
|
+
</div>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface TextFieldProps extends FieldCopy {
|
|
62
|
+
value: string
|
|
63
|
+
placeholder?: string
|
|
64
|
+
onCommit: (value: string) => void
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function TextField(props: TextFieldProps): ReactElement {
|
|
68
|
+
const id = useId()
|
|
69
|
+
const [draft, setDraft] = useState(props.value)
|
|
70
|
+
useEffect(() => { setDraft(props.value) }, [props.value])
|
|
71
|
+
return (
|
|
72
|
+
<div className={css['field']}>
|
|
73
|
+
<label className={css['label']} htmlFor={id}>{props.label}</label>
|
|
74
|
+
<Input
|
|
75
|
+
id={id}
|
|
76
|
+
className={css['input']}
|
|
77
|
+
type="text"
|
|
78
|
+
value={draft}
|
|
79
|
+
placeholder={props.placeholder ?? ''}
|
|
80
|
+
disabled={props.disabled}
|
|
81
|
+
onChange={(event) => {
|
|
82
|
+
setDraft(event.target.value)
|
|
83
|
+
props.onCommit(event.target.value)
|
|
84
|
+
}}
|
|
85
|
+
/>
|
|
86
|
+
<p className={css['hint']}>{props.hint}</p>
|
|
87
|
+
</div>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface NumberFieldProps extends FieldCopy {
|
|
92
|
+
invalidHint: string
|
|
93
|
+
min: number
|
|
94
|
+
value: number
|
|
95
|
+
onCommit: (value: number) => void
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function NumberField(props: NumberFieldProps): ReactElement {
|
|
99
|
+
const id = useId()
|
|
100
|
+
const [draft, setDraft] = useState(String(props.value))
|
|
101
|
+
const [invalid, setInvalid] = useState(false)
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
setDraft(String(props.value))
|
|
104
|
+
setInvalid(false)
|
|
105
|
+
}, [props.value])
|
|
106
|
+
return (
|
|
107
|
+
<div className={css['field']}>
|
|
108
|
+
<label className={css['label']} htmlFor={id}>{props.label}</label>
|
|
109
|
+
<Input
|
|
110
|
+
id={id}
|
|
111
|
+
className={`${css['input']} ${invalid ? css['inputInvalid'] : ''}`}
|
|
112
|
+
type="text"
|
|
113
|
+
inputMode="numeric"
|
|
114
|
+
value={draft}
|
|
115
|
+
disabled={props.disabled}
|
|
116
|
+
{...invalid ? { 'aria-invalid': true } : {}}
|
|
117
|
+
onChange={(event) => {
|
|
118
|
+
const text = event.target.value
|
|
119
|
+
const parsed = Number(text)
|
|
120
|
+
const acceptable = text.trim() !== '' && Number.isInteger(parsed) && parsed >= props.min
|
|
121
|
+
setDraft(text)
|
|
122
|
+
setInvalid(!acceptable)
|
|
123
|
+
if (acceptable && parsed !== props.value) props.onCommit(parsed)
|
|
124
|
+
}}
|
|
125
|
+
/>
|
|
126
|
+
<p className={invalid ? css['invalidHint'] : css['hint']} {...invalid ? { role: 'alert' } : {}}>
|
|
127
|
+
{invalid ? props.invalidHint : props.hint}
|
|
128
|
+
</p>
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/** Presentation-only compact view for the optional better-sidebar surface.
|
|
2
|
+
* Integration owns discovery and view-model derivation; this root subscribes
|
|
3
|
+
* once to locale changes for its complete presentation subtree. */
|
|
4
|
+
|
|
5
|
+
import { Button, StateDot, type StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
6
|
+
import { useState } from 'react'
|
|
7
|
+
import type { ReactElement } from 'react'
|
|
8
|
+
import {
|
|
9
|
+
agentGlyph,
|
|
10
|
+
formatRelativeTime,
|
|
11
|
+
normalizeStatus,
|
|
12
|
+
projectDisplayName,
|
|
13
|
+
type SessionCardVM,
|
|
14
|
+
type SessionStatusToken,
|
|
15
|
+
} from '../board/logic.ts'
|
|
16
|
+
import { t, type SidecarLocaleKey } from '../locales/index.ts'
|
|
17
|
+
import { useActiveLocale } from '../locales/react.ts'
|
|
18
|
+
import { StaticPill } from '../primitives/StaticPill.tsx'
|
|
19
|
+
import { surfaceProps } from '../theme/parts.ts'
|
|
20
|
+
import type { SidebarMiniVM } from './model.ts'
|
|
21
|
+
import css from './sidebar-tab.module.css'
|
|
22
|
+
|
|
23
|
+
const STATUS_LABEL_KEY: Record<SessionStatusToken, SidecarLocaleKey> = {
|
|
24
|
+
working: 'detail.status.working',
|
|
25
|
+
waiting: 'detail.status.waiting',
|
|
26
|
+
idle: 'detail.status.idle',
|
|
27
|
+
dead: 'detail.status.dead',
|
|
28
|
+
unknown: 'detail.status.unknown',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const CONNECTION_DOT_STATE: Record<SidebarMiniVM['connection'], StateDotState> = {
|
|
32
|
+
ok: 'done',
|
|
33
|
+
degraded: 'warning',
|
|
34
|
+
off: 'error',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SidebarTabProps {
|
|
38
|
+
vm: SidebarMiniVM
|
|
39
|
+
visible: boolean
|
|
40
|
+
/** Clock injection keeps SSR and focused view tests deterministic. */
|
|
41
|
+
nowMs?: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Icon renderer kept beside the view while preserving the descriptor callback API. */
|
|
45
|
+
export function SidebarTabIcon({ size }: { size: number }): ReactElement {
|
|
46
|
+
return (
|
|
47
|
+
<svg className={css['icon']} width={size} height={size} viewBox="0 0 24 24" aria-hidden>
|
|
48
|
+
<path d="M12 2 22 12 12 22 2 12 12 2Zm0 3.4L5.4 12l6.6 6.6 6.6-6.6L12 5.4Z" />
|
|
49
|
+
<circle cx="12" cy="12" r="2.2" />
|
|
50
|
+
</svg>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function SessionRow(props: {
|
|
55
|
+
session: SessionCardVM
|
|
56
|
+
nowMs: number
|
|
57
|
+
expanded: boolean
|
|
58
|
+
detailId: string
|
|
59
|
+
onToggle: () => void
|
|
60
|
+
}): ReactElement {
|
|
61
|
+
const { session, nowMs, expanded, detailId } = props
|
|
62
|
+
const status = normalizeStatus(session.status)
|
|
63
|
+
const title = session.title.trim() === '' ? t('sidebar.untitled') : session.title
|
|
64
|
+
const lastEvent =
|
|
65
|
+
session.lastEvent === null
|
|
66
|
+
? <span className={css['muted']}>{t('sidebar.noEvent')}</span>
|
|
67
|
+
: `${session.lastEvent.kind}: ${session.lastEvent.text}`
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<li className={css['sessionItem']}>
|
|
71
|
+
<Button
|
|
72
|
+
type="button"
|
|
73
|
+
size="sm"
|
|
74
|
+
variant="ghost"
|
|
75
|
+
className={css['sessionButton']}
|
|
76
|
+
onClick={props.onToggle}
|
|
77
|
+
data-testid="agent-sidecar-sidebar-session"
|
|
78
|
+
data-session-id={session.sessionId}
|
|
79
|
+
data-status={status}
|
|
80
|
+
aria-expanded={expanded}
|
|
81
|
+
aria-controls={detailId}
|
|
82
|
+
>
|
|
83
|
+
<span className={css['glyph']} aria-hidden>{agentGlyph(session.agent)}</span>
|
|
84
|
+
<span className={css['sessionTitle']} title={session.sessionId}>
|
|
85
|
+
{title}
|
|
86
|
+
</span>
|
|
87
|
+
<span className={css['sessionMeta']}>
|
|
88
|
+
{t(STATUS_LABEL_KEY[status])} · {formatRelativeTime(session.updatedAtMs, nowMs)}
|
|
89
|
+
</span>
|
|
90
|
+
</Button>
|
|
91
|
+
{expanded && (
|
|
92
|
+
<div
|
|
93
|
+
id={detailId}
|
|
94
|
+
className={css['detail']}
|
|
95
|
+
role="region"
|
|
96
|
+
aria-label={title}
|
|
97
|
+
data-testid="agent-sidecar-sidebar-detail"
|
|
98
|
+
>
|
|
99
|
+
<div>{projectDisplayName(session.project)}</div>
|
|
100
|
+
<div>{lastEvent}</div>
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
</li>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Compact better-sidebar body. No controller, service, or transport imports. */
|
|
108
|
+
export function SidebarTab({ vm, visible, nowMs = Date.now() }: SidebarTabProps): ReactElement {
|
|
109
|
+
useActiveLocale()
|
|
110
|
+
const [expandedId, setExpandedId] = useState<string | null>(null)
|
|
111
|
+
const recentTitleId = 'agent-sidecar-sidebar-recent-title'
|
|
112
|
+
|
|
113
|
+
let body: ReactElement
|
|
114
|
+
if (!vm.hasSnapshot) {
|
|
115
|
+
body = <p className={css['muted']}>{t('sidebar.connecting')}</p>
|
|
116
|
+
} else if (vm.recent.length === 0) {
|
|
117
|
+
body = <p className={css['muted']}>{t('sidebar.noSessions')}</p>
|
|
118
|
+
} else {
|
|
119
|
+
body = (
|
|
120
|
+
<ul className={css['sessionList']} aria-labelledby={recentTitleId}>
|
|
121
|
+
{vm.recent.map((session, index) => (
|
|
122
|
+
<SessionRow
|
|
123
|
+
key={session.sessionId}
|
|
124
|
+
session={session}
|
|
125
|
+
nowMs={nowMs}
|
|
126
|
+
expanded={expandedId === session.sessionId}
|
|
127
|
+
detailId={`agent-sidecar-sidebar-detail-${index}`}
|
|
128
|
+
onToggle={() => {
|
|
129
|
+
setExpandedId((previous) => previous === session.sessionId ? null : session.sessionId)
|
|
130
|
+
}}
|
|
131
|
+
/>
|
|
132
|
+
))}
|
|
133
|
+
</ul>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<section
|
|
139
|
+
{...surfaceProps('sidebar-tab', css['root'])}
|
|
140
|
+
data-testid="agent-sidecar-sidebar-tab"
|
|
141
|
+
data-visible={visible}
|
|
142
|
+
aria-label={t('sidebar.tabTitle')}
|
|
143
|
+
aria-hidden={!visible}
|
|
144
|
+
>
|
|
145
|
+
<header className={css['header']} title={vm.connectionTitle}>
|
|
146
|
+
<StaticPill className={css['counts']} data-testid="agent-sidecar-sidebar-counts">
|
|
147
|
+
<StateDot state={CONNECTION_DOT_STATE[vm.connection]} size={8} />
|
|
148
|
+
{t('sidebar.countsRow', { working: vm.workingCount, waiting: vm.waitingCount })}
|
|
149
|
+
</StaticPill>
|
|
150
|
+
</header>
|
|
151
|
+
<h2 id={recentTitleId} className={css['sectionTitle']}>{t('sidebar.recentTitle')}</h2>
|
|
152
|
+
{body}
|
|
153
|
+
<p className={css['hint']}>{t('sidebar.boardHint')}</p>
|
|
154
|
+
</section>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Pure view-model derivation for the optional compact sidebar. */
|
|
2
|
+
|
|
3
|
+
import type { SidecarViewState } from '../controller.ts'
|
|
4
|
+
import {
|
|
5
|
+
countWorking,
|
|
6
|
+
deriveWidgetConnection,
|
|
7
|
+
normalizeStatus,
|
|
8
|
+
type SessionCardVM,
|
|
9
|
+
type WidgetConnection,
|
|
10
|
+
widgetTitle,
|
|
11
|
+
} from '../board/logic.ts'
|
|
12
|
+
|
|
13
|
+
/** Compact view caps the session list at this many rows. */
|
|
14
|
+
export const MAX_RECENT_SESSIONS = 5
|
|
15
|
+
|
|
16
|
+
/** Count of sessions currently observed as waiting. */
|
|
17
|
+
export function countWaiting(sessions: ReadonlyArray<{ status: string }>): number {
|
|
18
|
+
let count = 0
|
|
19
|
+
for (const session of sessions) {
|
|
20
|
+
if (normalizeStatus(session.status) === 'waiting') count += 1
|
|
21
|
+
}
|
|
22
|
+
return count
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Return non-dead sessions by descending update time, capped for the compact
|
|
27
|
+
* view. Ties break by session id for stability.
|
|
28
|
+
*/
|
|
29
|
+
export function recentActiveSessions<T extends SessionCardVM>(
|
|
30
|
+
sessions: readonly T[],
|
|
31
|
+
limit: number = MAX_RECENT_SESSIONS,
|
|
32
|
+
): T[] {
|
|
33
|
+
return sessions
|
|
34
|
+
.filter((session) => normalizeStatus(session.status) !== 'dead')
|
|
35
|
+
.sort((a, b) =>
|
|
36
|
+
b.updatedAtMs !== a.updatedAtMs
|
|
37
|
+
? b.updatedAtMs - a.updatedAtMs
|
|
38
|
+
: a.sessionId.localeCompare(b.sessionId))
|
|
39
|
+
.slice(0, limit)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Everything the mini tab renders. */
|
|
43
|
+
export interface SidebarMiniVM {
|
|
44
|
+
connection: WidgetConnection
|
|
45
|
+
/** Hover text for the header dot (connection + working count). */
|
|
46
|
+
connectionTitle: string
|
|
47
|
+
workingCount: number
|
|
48
|
+
waitingCount: number
|
|
49
|
+
recent: SessionCardVM[]
|
|
50
|
+
hasSnapshot: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Fold the shared view state into the mini tab's view model. */
|
|
54
|
+
export function deriveMiniVM(state: SidecarViewState): SidebarMiniVM {
|
|
55
|
+
const connection = deriveWidgetConnection(state.daemonState, state.streamHealth)
|
|
56
|
+
const workingCount = countWorking(state.sessions)
|
|
57
|
+
return {
|
|
58
|
+
connection,
|
|
59
|
+
connectionTitle: widgetTitle(connection, workingCount),
|
|
60
|
+
workingCount,
|
|
61
|
+
waitingCount: countWaiting(state.sessions),
|
|
62
|
+
recent: recentActiveSessions(state.sessions),
|
|
63
|
+
hasSnapshot: state.hasSnapshot,
|
|
64
|
+
}
|
|
65
|
+
}
|