@shendeguize/dsh-agent-sidecar 0.1.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/LICENSE +21 -0
- package/README.md +167 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +8062 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +396 -0
- package/lib/index.js +4166 -0
- package/package.json +101 -0
- package/src/analysis.ts +782 -0
- package/src/bridge.ts +841 -0
- package/src/client/analysis/AnalysisPanel.tsx +191 -0
- package/src/client/analysis/analysis.module.css +183 -0
- package/src/client/analysis-glue.ts +331 -0
- package/src/client/api.ts +380 -0
- package/src/client/board/Board.tsx +214 -0
- package/src/client/board/board.module.css +302 -0
- package/src/client/board/logic.ts +556 -0
- package/src/client/board/project-view-logic.ts +361 -0
- package/src/client/board/project-view.module.css +307 -0
- package/src/client/board/project-view.tsx +189 -0
- package/src/client/board/strings.ts +112 -0
- package/src/client/commands.ts +484 -0
- package/src/client/controller.ts +360 -0
- package/src/client/css-modules.d.ts +11 -0
- package/src/client/detail/SessionDetail.tsx +270 -0
- package/src/client/detail/detail.module.css +433 -0
- package/src/client/detail/logic.ts +779 -0
- package/src/client/detail/strings.ts +98 -0
- package/src/client/detail/transport.ts +175 -0
- package/src/client/detail-glue.ts +397 -0
- package/src/client/detail-view.module.css +79 -0
- package/src/client/detail-view.tsx +233 -0
- package/src/client/dsh-tools/LineageTree.tsx +210 -0
- package/src/client/dsh-tools/SearchPanel.tsx +169 -0
- package/src/client/dsh-tools/dsh-tools.module.css +374 -0
- package/src/client/dsh-tools/logic.ts +596 -0
- package/src/client/dsh-tools/strings.ts +90 -0
- package/src/client/index.ts +315 -0
- package/src/client/inject/InjectPanel.tsx +482 -0
- package/src/client/inject/inject.module.css +446 -0
- package/src/client/inject/logic.ts +516 -0
- package/src/client/inject/overlay.module.css +22 -0
- package/src/client/inject-glue.ts +171 -0
- package/src/client/locales/command.ts +48 -0
- package/src/client/locales/en.ts +385 -0
- package/src/client/locales/index.ts +123 -0
- package/src/client/locales/zh.ts +402 -0
- package/src/client/m3-transport.ts +151 -0
- package/src/client/mount.tsx +307 -0
- package/src/client/project-glue.ts +134 -0
- package/src/client/search-glue.ts +143 -0
- package/src/client/settings-card.module.css +359 -0
- package/src/client/settings-card.tsx +565 -0
- package/src/client/settings-glue.ts +130 -0
- package/src/client/sidebar-tab.tsx +494 -0
- package/src/client/sse.ts +366 -0
- package/src/client/widget.tsx +80 -0
- package/src/config.ts +193 -0
- package/src/dsh-inject.ts +240 -0
- package/src/fusion.ts +988 -0
- package/src/guard.ts +274 -0
- package/src/index.ts +950 -0
- package/src/inject-gateway.ts +574 -0
- package/src/routes.ts +1133 -0
- package/src/send-cli.ts +340 -0
- package/src/session-store.ts +184 -0
- package/src/skills-provider.ts +293 -0
- package/src/supervisor.ts +463 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional better-sidebar mini tab (T6.3, design §5.2 / ADR-5 option C).
|
|
3
|
+
*
|
|
4
|
+
* dsh-better-sidebar (npm, ≥0.4.0) turns the sidebar into a registry
|
|
5
|
+
* service: its client half runs `ctx.provide('betterSidebar', service)` and
|
|
6
|
+
* consumers call `service.registerTab(descriptor)` (source of record:
|
|
7
|
+
* better-sidebar docs/external-plugin-guide.md + src/client/service.ts).
|
|
8
|
+
* This module integrates as a SOFT dependency, dsh-sentinel style:
|
|
9
|
+
*
|
|
10
|
+
* - NO value import and NO type import of `dsh-better-sidebar` — the client
|
|
11
|
+
* bundle purity gate stays react-only. The minimal service contract is
|
|
12
|
+
* restated locally ({@link BetterSidebarServiceFace}) and the live service
|
|
13
|
+
* is duck-typed at runtime ({@link probeBetterSidebar}).
|
|
14
|
+
* - Absent → silent skip: {@link mountSidebarTab} parks the registration in
|
|
15
|
+
* `ctx.inject(['betterSidebar'], …)` — a cordis fiber that stays PENDING
|
|
16
|
+
* until some plugin provides the service (zero timers, zero polling, zero
|
|
17
|
+
* resources), plus one debug-level log line at mount time. If
|
|
18
|
+
* better-sidebar is installed later (any load order), the fiber activates
|
|
19
|
+
* and the tab registers; if never, nothing ever runs.
|
|
20
|
+
* - Present → one compact "Sidecar" tab (`agent-sidecar:monitor`): daemon
|
|
21
|
+
* connection dot, working/waiting counts, and the most recently active
|
|
22
|
+
* sessions, all read from the SHARED {@link SidecarController} stores —
|
|
23
|
+
* no second poller, no second SSE.
|
|
24
|
+
* - `visible === false` releases resources: better-sidebar hands tab
|
|
25
|
+
* components a `visible` prop (false while the panel is collapsed or
|
|
26
|
+
* another tab is active). The shared controller has no pause/refcount
|
|
27
|
+
* surface (its stream is plugin-lifetime, `stop()` is terminal), so the
|
|
28
|
+
* gate lives in this view: {@link VisibleGatedStore} unsubscribes from the
|
|
29
|
+
* controller while hidden (re-render churn stops; the view triggers no
|
|
30
|
+
* fetches of its own) and resubscribes + catches up on show.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { createElement as h, useEffect, useState, useSyncExternalStore } from 'react'
|
|
34
|
+
import type { CSSProperties, ReactElement, ReactNode } from 'react'
|
|
35
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
36
|
+
import type { SidecarController, SidecarViewState } from './controller.ts'
|
|
37
|
+
import {
|
|
38
|
+
agentGlyph,
|
|
39
|
+
countWorking,
|
|
40
|
+
deriveWidgetConnection,
|
|
41
|
+
formatRelativeTime,
|
|
42
|
+
normalizeStatus,
|
|
43
|
+
projectDisplayName,
|
|
44
|
+
widgetTitle,
|
|
45
|
+
type SessionCardVM,
|
|
46
|
+
type SessionStatusToken,
|
|
47
|
+
type WidgetConnection,
|
|
48
|
+
} from './board/logic.ts'
|
|
49
|
+
import { t, type SidecarLocaleKey } from './locales/index.ts'
|
|
50
|
+
|
|
51
|
+
/** Registered tab type id (design §5.2 names it verbatim). */
|
|
52
|
+
export const SIDEBAR_TAB_ID = 'agent-sidecar:monitor'
|
|
53
|
+
|
|
54
|
+
/** `+`-menu order: after the built-ins (explorer=10 … browser=50). */
|
|
55
|
+
export const SIDEBAR_TAB_ORDER = 60
|
|
56
|
+
|
|
57
|
+
/** Compact view caps the session list at this many rows (task spec: 3-5). */
|
|
58
|
+
export const MAX_RECENT_SESSIONS = 5
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Duck-typed better-sidebar contract (locally restated, never imported).
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/** Props better-sidebar hands every tab component; only `visible` is used. */
|
|
65
|
+
export interface SidebarTabComponentProps {
|
|
66
|
+
/** False while the panel is collapsed or another tab is active. */
|
|
67
|
+
visible: boolean
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The one descriptor shape this module registers (subset of TabDescriptor). */
|
|
71
|
+
export interface SidebarTabDescriptor {
|
|
72
|
+
id: string
|
|
73
|
+
title: string | (() => string)
|
|
74
|
+
icon?: (size: number) => ReactNode
|
|
75
|
+
order?: number
|
|
76
|
+
/** Single-instance sugar: opening again focuses the existing tab. */
|
|
77
|
+
single?: boolean
|
|
78
|
+
component: (props: SidebarTabComponentProps) => ReactNode
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Minimal face of `ctx.betterSidebar` this module relies on. */
|
|
82
|
+
export interface BetterSidebarServiceFace {
|
|
83
|
+
/** Registers a tab type; returns the disposer. Throws on duplicate id. */
|
|
84
|
+
registerTab(descriptor: SidebarTabDescriptor): () => void
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Duck-type a candidate service value: any object exposing a callable
|
|
89
|
+
* `registerTab` qualifies (the registry contract is stable since
|
|
90
|
+
* better-sidebar v0.4.0). Anything else — absent service, or a foreign
|
|
91
|
+
* object squatting on the service name — reads as "not installed".
|
|
92
|
+
*/
|
|
93
|
+
export function probeBetterSidebar(candidate: unknown): BetterSidebarServiceFace | null {
|
|
94
|
+
if (typeof candidate !== 'object' || candidate === null) return null
|
|
95
|
+
const face = candidate as { registerTab?: unknown }
|
|
96
|
+
return typeof face.registerTab === 'function'
|
|
97
|
+
? (candidate as BetterSidebarServiceFace)
|
|
98
|
+
: null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Visibility gate over the shared controller.
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
/** The slice of {@link SidecarController} this view consumes (tests fake it). */
|
|
106
|
+
export type SidecarStateSource = Pick<SidecarController, 'subscribe' | 'getState'>
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A pausable read-through store between the shared controller and one tab
|
|
110
|
+
* instance. While visible it mirrors the controller (subscribed, snapshots
|
|
111
|
+
* flow, listeners notified); while hidden it holds NO controller
|
|
112
|
+
* subscription — upstream notifications cost this view nothing — and serves
|
|
113
|
+
* the last seen snapshot. Turning visible again resubscribes and catches up
|
|
114
|
+
* once. Methods are bound fields so uSES sees stable identities.
|
|
115
|
+
*/
|
|
116
|
+
export class VisibleGatedStore {
|
|
117
|
+
private readonly source: SidecarStateSource
|
|
118
|
+
private readonly listeners = new Set<() => void>()
|
|
119
|
+
private unsubscribe: (() => void) | null = null
|
|
120
|
+
private snapshot: SidecarViewState
|
|
121
|
+
private disposed = false
|
|
122
|
+
|
|
123
|
+
constructor(source: SidecarStateSource) {
|
|
124
|
+
this.source = source
|
|
125
|
+
this.snapshot = source.getState()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Whether the upstream controller subscription is currently held. */
|
|
129
|
+
get subscribed(): boolean {
|
|
130
|
+
return this.unsubscribe !== null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
readonly subscribe = (listener: () => void): (() => void) => {
|
|
134
|
+
this.listeners.add(listener)
|
|
135
|
+
return () => {
|
|
136
|
+
this.listeners.delete(listener)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
readonly getState = (): SidecarViewState => this.snapshot
|
|
141
|
+
|
|
142
|
+
/** Idempotent visibility switch: subscribe + catch up, or unsubscribe. */
|
|
143
|
+
setVisible(visible: boolean): void {
|
|
144
|
+
if (this.disposed || visible === this.subscribed) return
|
|
145
|
+
if (visible) {
|
|
146
|
+
this.unsubscribe = this.source.subscribe(() => {
|
|
147
|
+
this.pull()
|
|
148
|
+
})
|
|
149
|
+
this.pull()
|
|
150
|
+
} else {
|
|
151
|
+
this.unsubscribe?.()
|
|
152
|
+
this.unsubscribe = null
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Terminal teardown (component unmount): drop upstream and listeners. */
|
|
157
|
+
dispose(): void {
|
|
158
|
+
this.disposed = true
|
|
159
|
+
this.unsubscribe?.()
|
|
160
|
+
this.unsubscribe = null
|
|
161
|
+
this.listeners.clear()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private pull(): void {
|
|
165
|
+
const next = this.source.getState()
|
|
166
|
+
if (next === this.snapshot) return
|
|
167
|
+
this.snapshot = next
|
|
168
|
+
for (const fn of [...this.listeners]) fn()
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Pure view model (exported for tests).
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
/** Count of sessions currently observed as waiting. */
|
|
177
|
+
export function countWaiting(sessions: ReadonlyArray<{ status: string }>): number {
|
|
178
|
+
let count = 0
|
|
179
|
+
for (const session of sessions) {
|
|
180
|
+
if (normalizeStatus(session.status) === 'waiting') count += 1
|
|
181
|
+
}
|
|
182
|
+
return count
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The compact list: non-dead sessions, most recently updated first, capped
|
|
187
|
+
* at {@link MAX_RECENT_SESSIONS} (ties break by session id for stability).
|
|
188
|
+
*/
|
|
189
|
+
export function recentActiveSessions<T extends SessionCardVM>(
|
|
190
|
+
sessions: readonly T[],
|
|
191
|
+
limit: number = MAX_RECENT_SESSIONS,
|
|
192
|
+
): T[] {
|
|
193
|
+
return sessions
|
|
194
|
+
.filter((session) => normalizeStatus(session.status) !== 'dead')
|
|
195
|
+
.sort((a, b) =>
|
|
196
|
+
b.updatedAtMs !== a.updatedAtMs
|
|
197
|
+
? b.updatedAtMs - a.updatedAtMs
|
|
198
|
+
: a.sessionId.localeCompare(b.sessionId))
|
|
199
|
+
.slice(0, limit)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Everything the mini tab renders. */
|
|
203
|
+
export interface SidebarMiniVM {
|
|
204
|
+
connection: WidgetConnection
|
|
205
|
+
/** Hover text for the header dot (connection + working count). */
|
|
206
|
+
connectionTitle: string
|
|
207
|
+
workingCount: number
|
|
208
|
+
waitingCount: number
|
|
209
|
+
recent: SessionCardVM[]
|
|
210
|
+
hasSnapshot: boolean
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Fold the shared view state into the mini tab's view model (pure). */
|
|
214
|
+
export function deriveMiniVM(state: SidecarViewState): SidebarMiniVM {
|
|
215
|
+
const connection = deriveWidgetConnection(state.daemonState, state.streamHealth)
|
|
216
|
+
const workingCount = countWorking(state.sessions)
|
|
217
|
+
return {
|
|
218
|
+
connection,
|
|
219
|
+
connectionTitle: widgetTitle(connection, workingCount),
|
|
220
|
+
workingCount,
|
|
221
|
+
waitingCount: countWaiting(state.sessions),
|
|
222
|
+
recent: recentActiveSessions(state.sessions),
|
|
223
|
+
hasSnapshot: state.hasSnapshot,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// Presentation.
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
const STATUS_LABEL_KEY: Record<SessionStatusToken, SidecarLocaleKey> = {
|
|
232
|
+
working: 'detail.status.working',
|
|
233
|
+
waiting: 'detail.status.waiting',
|
|
234
|
+
idle: 'detail.status.idle',
|
|
235
|
+
dead: 'detail.status.dead',
|
|
236
|
+
unknown: 'detail.status.unknown',
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const DOT_COLORS: Record<WidgetConnection, string> = {
|
|
240
|
+
ok: 'var(--dsw-alias-state-success-primary, #1a7f37)',
|
|
241
|
+
degraded: 'var(--dsw-alias-state-warn-primary, #9a6700)',
|
|
242
|
+
off: 'var(--dsw-alias-label-dimmed, #8c959f)',
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const rootStyle: CSSProperties = {
|
|
246
|
+
display: 'flex',
|
|
247
|
+
flexDirection: 'column',
|
|
248
|
+
gap: 8,
|
|
249
|
+
padding: '10px 12px',
|
|
250
|
+
fontSize: 12,
|
|
251
|
+
color: 'var(--dsw-alias-label-primary, #1f2328)',
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const headerStyle: CSSProperties = {
|
|
255
|
+
display: 'flex',
|
|
256
|
+
alignItems: 'center',
|
|
257
|
+
gap: 6,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const dotStyle = (connection: WidgetConnection): CSSProperties => ({
|
|
261
|
+
width: 8,
|
|
262
|
+
height: 8,
|
|
263
|
+
borderRadius: '50%',
|
|
264
|
+
flex: 'none',
|
|
265
|
+
background: DOT_COLORS[connection],
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
const countsStyle: CSSProperties = {
|
|
269
|
+
fontVariantNumeric: 'tabular-nums',
|
|
270
|
+
color: 'var(--dsw-alias-label-secondary, #57606a)',
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const sectionTitleStyle: CSSProperties = {
|
|
274
|
+
fontSize: 11,
|
|
275
|
+
fontWeight: 600,
|
|
276
|
+
color: 'var(--dsw-alias-label-secondary, #57606a)',
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const rowStyle: CSSProperties = {
|
|
280
|
+
display: 'flex',
|
|
281
|
+
alignItems: 'center',
|
|
282
|
+
gap: 6,
|
|
283
|
+
width: '100%',
|
|
284
|
+
padding: '4px 6px',
|
|
285
|
+
border: 'none',
|
|
286
|
+
borderRadius: 6,
|
|
287
|
+
background: 'transparent',
|
|
288
|
+
cursor: 'pointer',
|
|
289
|
+
font: 'inherit',
|
|
290
|
+
textAlign: 'left',
|
|
291
|
+
color: 'inherit',
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const rowTitleStyle: CSSProperties = {
|
|
295
|
+
flex: 1,
|
|
296
|
+
minWidth: 0,
|
|
297
|
+
overflow: 'hidden',
|
|
298
|
+
textOverflow: 'ellipsis',
|
|
299
|
+
whiteSpace: 'nowrap',
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const rowMetaStyle: CSSProperties = {
|
|
303
|
+
flex: 'none',
|
|
304
|
+
fontSize: 11,
|
|
305
|
+
color: 'var(--dsw-alias-label-dimmed, #8c959f)',
|
|
306
|
+
whiteSpace: 'nowrap',
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const detailStyle: CSSProperties = {
|
|
310
|
+
margin: '0 6px 4px 20px',
|
|
311
|
+
fontSize: 11,
|
|
312
|
+
lineHeight: '16px',
|
|
313
|
+
color: 'var(--dsw-alias-label-secondary, #57606a)',
|
|
314
|
+
overflowWrap: 'anywhere',
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const mutedStyle: CSSProperties = {
|
|
318
|
+
color: 'var(--dsw-alias-label-dimmed, #8c959f)',
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const hintStyle: CSSProperties = {
|
|
322
|
+
fontSize: 11,
|
|
323
|
+
color: 'var(--dsw-alias-label-dimmed, #8c959f)',
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** One session row + its inline expansion (last event, project, id). */
|
|
327
|
+
function SessionRow(props: {
|
|
328
|
+
session: SessionCardVM
|
|
329
|
+
nowMs: number
|
|
330
|
+
expanded: boolean
|
|
331
|
+
onToggle: () => void
|
|
332
|
+
}): ReactElement {
|
|
333
|
+
const { session, nowMs, expanded } = props
|
|
334
|
+
const status = normalizeStatus(session.status)
|
|
335
|
+
const title = session.title.trim() === '' ? t('sidebar.untitled') : session.title
|
|
336
|
+
const rowChildren: ReactNode[] = [
|
|
337
|
+
h('span', { key: 'glyph', 'aria-hidden': true }, agentGlyph(session.agent)),
|
|
338
|
+
h('span', { key: 'title', style: rowTitleStyle, title: session.sessionId }, title),
|
|
339
|
+
h(
|
|
340
|
+
'span',
|
|
341
|
+
{ key: 'meta', style: rowMetaStyle },
|
|
342
|
+
`${t(STATUS_LABEL_KEY[status])} · ${formatRelativeTime(session.updatedAtMs, nowMs)}`,
|
|
343
|
+
),
|
|
344
|
+
]
|
|
345
|
+
const children: ReactNode[] = [
|
|
346
|
+
h(
|
|
347
|
+
'button',
|
|
348
|
+
{
|
|
349
|
+
key: 'row',
|
|
350
|
+
type: 'button',
|
|
351
|
+
style: rowStyle,
|
|
352
|
+
onClick: props.onToggle,
|
|
353
|
+
'data-testid': 'agent-sidecar-sidebar-session',
|
|
354
|
+
'data-session-id': session.sessionId,
|
|
355
|
+
'data-status': status,
|
|
356
|
+
'aria-expanded': expanded,
|
|
357
|
+
},
|
|
358
|
+
...rowChildren,
|
|
359
|
+
),
|
|
360
|
+
]
|
|
361
|
+
if (expanded) {
|
|
362
|
+
const lastEvent =
|
|
363
|
+
session.lastEvent === null
|
|
364
|
+
? h('span', { style: mutedStyle }, t('sidebar.noEvent'))
|
|
365
|
+
: `${session.lastEvent.kind}: ${session.lastEvent.text}`
|
|
366
|
+
children.push(
|
|
367
|
+
h(
|
|
368
|
+
'div',
|
|
369
|
+
{ key: 'detail', style: detailStyle, 'data-testid': 'agent-sidecar-sidebar-detail' },
|
|
370
|
+
h('div', { key: 'project' }, projectDisplayName(session.project)),
|
|
371
|
+
h('div', { key: 'event' }, lastEvent),
|
|
372
|
+
),
|
|
373
|
+
)
|
|
374
|
+
}
|
|
375
|
+
return h('li', { style: { listStyle: 'none' } }, ...children)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Build the tab component bound to the shared controller. One
|
|
380
|
+
* {@link VisibleGatedStore} per mounted tab instance: the `visible` prop is
|
|
381
|
+
* synced into it by effect, unmount disposes it.
|
|
382
|
+
*/
|
|
383
|
+
export function createSidebarTabComponent(
|
|
384
|
+
controller: SidecarStateSource,
|
|
385
|
+
): (props: SidebarTabComponentProps) => ReactElement {
|
|
386
|
+
return function SidecarSidebarTab({ visible }: SidebarTabComponentProps): ReactElement {
|
|
387
|
+
const [gate] = useState(() => new VisibleGatedStore(controller))
|
|
388
|
+
useEffect(() => () => {
|
|
389
|
+
gate.dispose()
|
|
390
|
+
}, [gate])
|
|
391
|
+
useEffect(() => {
|
|
392
|
+
gate.setVisible(visible)
|
|
393
|
+
}, [gate, visible])
|
|
394
|
+
const state = useSyncExternalStore(gate.subscribe, gate.getState, gate.getState)
|
|
395
|
+
const [expandedId, setExpandedId] = useState<string | null>(null)
|
|
396
|
+
|
|
397
|
+
const vm = deriveMiniVM(state)
|
|
398
|
+
const nowMs = Date.now()
|
|
399
|
+
|
|
400
|
+
let body: ReactNode
|
|
401
|
+
if (!vm.hasSnapshot) {
|
|
402
|
+
body = h('div', { style: mutedStyle }, t('sidebar.connecting'))
|
|
403
|
+
} else if (vm.recent.length === 0) {
|
|
404
|
+
body = h('div', { style: mutedStyle }, t('sidebar.noSessions'))
|
|
405
|
+
} else {
|
|
406
|
+
body = h(
|
|
407
|
+
'ul',
|
|
408
|
+
{ style: { margin: 0, padding: 0 } },
|
|
409
|
+
...vm.recent.map((session) =>
|
|
410
|
+
h(SessionRow, {
|
|
411
|
+
key: session.sessionId,
|
|
412
|
+
session,
|
|
413
|
+
nowMs,
|
|
414
|
+
expanded: expandedId === session.sessionId,
|
|
415
|
+
onToggle: () => {
|
|
416
|
+
setExpandedId((prev) => (prev === session.sessionId ? null : session.sessionId))
|
|
417
|
+
},
|
|
418
|
+
})),
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return h(
|
|
423
|
+
'div',
|
|
424
|
+
{ style: rootStyle, 'data-testid': 'agent-sidecar-sidebar-tab', 'data-visible': visible },
|
|
425
|
+
h(
|
|
426
|
+
'div',
|
|
427
|
+
{ style: headerStyle, title: vm.connectionTitle },
|
|
428
|
+
h('span', { 'aria-hidden': true, style: dotStyle(vm.connection) }),
|
|
429
|
+
h(
|
|
430
|
+
'span',
|
|
431
|
+
{ style: countsStyle, 'data-testid': 'agent-sidecar-sidebar-counts' },
|
|
432
|
+
t('sidebar.countsRow', { working: vm.workingCount, waiting: vm.waitingCount }),
|
|
433
|
+
),
|
|
434
|
+
),
|
|
435
|
+
h('div', { style: sectionTitleStyle }, t('sidebar.recentTitle')),
|
|
436
|
+
body,
|
|
437
|
+
h('div', { style: hintStyle }, t('sidebar.boardHint')),
|
|
438
|
+
)
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
// Mount.
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
/** Context slice {@link mountSidebarTab} consumes (tests hand in a fake). */
|
|
447
|
+
export interface SidebarMountContext {
|
|
448
|
+
get(name: string): unknown
|
|
449
|
+
inject(deps: string[], callback: (ctx: unknown) => void): unknown
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Park the better-sidebar integration on the optional service. Not
|
|
454
|
+
* installed → the inject fiber never activates (silent skip, one debug
|
|
455
|
+
* line, zero resources). Installed (before or after this plugin, order
|
|
456
|
+
* does not matter) → duck-type the service and register the mini tab
|
|
457
|
+
* inside `ctx.effect`, so plugin unload / HMR unregisters it. A duplicate
|
|
458
|
+
* registration (double apply) or a service throw degrades to a logged
|
|
459
|
+
* no-op — never past this seat.
|
|
460
|
+
*/
|
|
461
|
+
export function mountSidebarTab(ctx: ClientContext, controller: SidecarStateSource): void {
|
|
462
|
+
const mount = ctx as unknown as SidebarMountContext
|
|
463
|
+
if (probeBetterSidebar(mount.get('betterSidebar')) === null) {
|
|
464
|
+
console.debug(
|
|
465
|
+
'agent-sidecar: better-sidebar not detected; the optional sidebar tab stays idle',
|
|
466
|
+
)
|
|
467
|
+
}
|
|
468
|
+
mount.inject(['betterSidebar'], (injected) => {
|
|
469
|
+
const bctx = injected as ClientContext & SidebarMountContext
|
|
470
|
+
const service = probeBetterSidebar(bctx.get('betterSidebar'))
|
|
471
|
+
if (service === null) {
|
|
472
|
+
// Provided but not duck-compatible (e.g. a future breaking rewrite).
|
|
473
|
+
console.debug('agent-sidecar: betterSidebar service lacks registerTab; skipping tab')
|
|
474
|
+
return
|
|
475
|
+
}
|
|
476
|
+
const component = createSidebarTabComponent(controller)
|
|
477
|
+
bctx.effect(() => {
|
|
478
|
+
try {
|
|
479
|
+
return service.registerTab({
|
|
480
|
+
id: SIDEBAR_TAB_ID,
|
|
481
|
+
title: () => t('sidebar.tabTitle'),
|
|
482
|
+
icon: (size: number) =>
|
|
483
|
+
h('span', { 'aria-hidden': true, style: { fontSize: Math.round(size * 0.75) } }, '◈'),
|
|
484
|
+
order: SIDEBAR_TAB_ORDER,
|
|
485
|
+
single: true,
|
|
486
|
+
component,
|
|
487
|
+
})
|
|
488
|
+
} catch (err) {
|
|
489
|
+
console.error('agent-sidecar: better-sidebar tab registration failed', err)
|
|
490
|
+
return () => {}
|
|
491
|
+
}
|
|
492
|
+
}, 'agent-sidecar: better-sidebar tab')
|
|
493
|
+
})
|
|
494
|
+
}
|