@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.
Files changed (58) hide show
  1. package/README.md +78 -11
  2. package/lib/client.js +4708 -2932
  3. package/lib/client.js.map +1 -1
  4. package/package.json +3 -1
  5. package/src/client/analysis/AnalysisPanel.tsx +19 -27
  6. package/src/client/analysis/analysis.module.css +36 -97
  7. package/src/client/board/Board.tsx +255 -48
  8. package/src/client/board/board.module.css +113 -92
  9. package/src/client/board/logic.ts +99 -21
  10. package/src/client/board/project-view-logic.ts +27 -34
  11. package/src/client/board/project-view.module.css +46 -83
  12. package/src/client/board/project-view.tsx +50 -8
  13. package/src/client/board/strings.ts +70 -85
  14. package/src/client/commands.ts +30 -15
  15. package/src/client/controller.ts +27 -7
  16. package/src/client/detail/SessionDetail.tsx +234 -37
  17. package/src/client/detail/detail.module.css +100 -153
  18. package/src/client/detail/logic.ts +220 -29
  19. package/src/client/detail/strings.ts +66 -77
  20. package/src/client/detail-glue.ts +33 -0
  21. package/src/client/detail-view.module.css +43 -35
  22. package/src/client/detail-view.tsx +138 -118
  23. package/src/client/dsh-tools/LineageTree.tsx +22 -13
  24. package/src/client/dsh-tools/SearchPanel.tsx +18 -11
  25. package/src/client/dsh-tools/dsh-tools.module.css +53 -140
  26. package/src/client/dsh-tools/strings.ts +42 -77
  27. package/src/client/index.ts +285 -124
  28. package/src/client/inject/InjectPanel.tsx +81 -61
  29. package/src/client/inject/inject.module.css +97 -197
  30. package/src/client/inject/logic.ts +38 -0
  31. package/src/client/lifecycle/handoff.ts +83 -0
  32. package/src/client/locales/en.ts +91 -4
  33. package/src/client/locales/host.ts +131 -0
  34. package/src/client/locales/index.ts +196 -8
  35. package/src/client/locales/react.ts +10 -0
  36. package/src/client/locales/view.ts +38 -0
  37. package/src/client/locales/zh.ts +200 -125
  38. package/src/client/mount.tsx +75 -41
  39. package/src/client/navigation/CenterOverlay.tsx +40 -0
  40. package/src/client/navigation/center-overlay.module.css +51 -0
  41. package/src/client/navigation/center.ts +45 -0
  42. package/src/client/navigation/modal-isolation.ts +152 -0
  43. package/src/client/navigation/modal-surface-anchor.ts +72 -0
  44. package/src/client/navigation/sidebar-entry.module.css +73 -0
  45. package/src/client/navigation/sidebar-entry.ts +309 -0
  46. package/src/client/primitives/StaticPill.tsx +21 -0
  47. package/src/client/settings-card.module.css +35 -119
  48. package/src/client/settings-card.tsx +31 -153
  49. package/src/client/settings-fields.tsx +131 -0
  50. package/src/client/sidebar/SidebarTab.tsx +156 -0
  51. package/src/client/sidebar/model.ts +65 -0
  52. package/src/client/sidebar/sidebar-tab.module.css +118 -0
  53. package/src/client/sidebar-tab.tsx +46 -320
  54. package/src/client/theme/agsc.module.css +31 -0
  55. package/src/client/theme/parts.ts +56 -0
  56. package/src/client/ui-integration.ts +86 -0
  57. package/src/client/widget.tsx +42 -16
  58. 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 { useEffect, useId, useState } from 'react'
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 className={`${css['card']} ${open ? css['cardOpen'] : ''}`}>
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 ? <span className={css['pending']}>{t('settings.unsaved')}</span> : null}
352
- <span className={`${css['chevron']} ${open ? css['chevronOpen'] : ''}`} aria-hidden />
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
- <button
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
- </button>
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
- <button
419
+ <Button
544
420
  type="button"
545
- className={css['discard']}
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
- </button>
551
- <button
427
+ </Button>
428
+ <Button
552
429
  type="button"
553
- className={css['save']}
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
- </button>
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
+ }