@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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +167 -0
  3. package/cordis.patch.yml +10 -0
  4. package/lib/client.js +8062 -0
  5. package/lib/client.js.map +1 -0
  6. package/lib/index.d.ts +396 -0
  7. package/lib/index.js +4166 -0
  8. package/package.json +101 -0
  9. package/src/analysis.ts +782 -0
  10. package/src/bridge.ts +841 -0
  11. package/src/client/analysis/AnalysisPanel.tsx +191 -0
  12. package/src/client/analysis/analysis.module.css +183 -0
  13. package/src/client/analysis-glue.ts +331 -0
  14. package/src/client/api.ts +380 -0
  15. package/src/client/board/Board.tsx +214 -0
  16. package/src/client/board/board.module.css +302 -0
  17. package/src/client/board/logic.ts +556 -0
  18. package/src/client/board/project-view-logic.ts +361 -0
  19. package/src/client/board/project-view.module.css +307 -0
  20. package/src/client/board/project-view.tsx +189 -0
  21. package/src/client/board/strings.ts +112 -0
  22. package/src/client/commands.ts +484 -0
  23. package/src/client/controller.ts +360 -0
  24. package/src/client/css-modules.d.ts +11 -0
  25. package/src/client/detail/SessionDetail.tsx +270 -0
  26. package/src/client/detail/detail.module.css +433 -0
  27. package/src/client/detail/logic.ts +779 -0
  28. package/src/client/detail/strings.ts +98 -0
  29. package/src/client/detail/transport.ts +175 -0
  30. package/src/client/detail-glue.ts +397 -0
  31. package/src/client/detail-view.module.css +79 -0
  32. package/src/client/detail-view.tsx +233 -0
  33. package/src/client/dsh-tools/LineageTree.tsx +210 -0
  34. package/src/client/dsh-tools/SearchPanel.tsx +169 -0
  35. package/src/client/dsh-tools/dsh-tools.module.css +374 -0
  36. package/src/client/dsh-tools/logic.ts +596 -0
  37. package/src/client/dsh-tools/strings.ts +90 -0
  38. package/src/client/index.ts +315 -0
  39. package/src/client/inject/InjectPanel.tsx +482 -0
  40. package/src/client/inject/inject.module.css +446 -0
  41. package/src/client/inject/logic.ts +516 -0
  42. package/src/client/inject/overlay.module.css +22 -0
  43. package/src/client/inject-glue.ts +171 -0
  44. package/src/client/locales/command.ts +48 -0
  45. package/src/client/locales/en.ts +385 -0
  46. package/src/client/locales/index.ts +123 -0
  47. package/src/client/locales/zh.ts +402 -0
  48. package/src/client/m3-transport.ts +151 -0
  49. package/src/client/mount.tsx +307 -0
  50. package/src/client/project-glue.ts +134 -0
  51. package/src/client/search-glue.ts +143 -0
  52. package/src/client/settings-card.module.css +359 -0
  53. package/src/client/settings-card.tsx +565 -0
  54. package/src/client/settings-glue.ts +130 -0
  55. package/src/client/sidebar-tab.tsx +494 -0
  56. package/src/client/sse.ts +366 -0
  57. package/src/client/widget.tsx +80 -0
  58. package/src/config.ts +193 -0
  59. package/src/dsh-inject.ts +240 -0
  60. package/src/fusion.ts +988 -0
  61. package/src/guard.ts +274 -0
  62. package/src/index.ts +950 -0
  63. package/src/inject-gateway.ts +574 -0
  64. package/src/routes.ts +1133 -0
  65. package/src/send-cli.ts +340 -0
  66. package/src/session-store.ts +184 -0
  67. package/src/skills-provider.ts +293 -0
  68. package/src/supervisor.ts +463 -0
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Transport helpers for the M3 deep-query read endpoints (lineage /
3
+ * search / projects), completing what detail/transport.ts started for
4
+ * `session/<id>` + timeline. Same posture as api.ts: same-origin relative
5
+ * paths under {@link API_PREFIX}, bounded timeout, normalized ApiError,
6
+ * injectable browser primitives so node tests run without a DOM.
7
+ *
8
+ * Response typing reuses the components' own wire mirrors
9
+ * (dsh-tools/logic.ts, board/project-view-logic.ts) so the transport and
10
+ * the render pipelines can never disagree about a shape.
11
+ *
12
+ * Read-only surface, no retry policy here (transport, not policy).
13
+ *
14
+ * @module
15
+ */
16
+
17
+ import {
18
+ API_PREFIX,
19
+ ApiError,
20
+ DEFAULT_TIMEOUT_MS,
21
+ type AbortControllerLike,
22
+ type FetchLike,
23
+ type RequestOptions,
24
+ type ResponseLike,
25
+ type TimerHandle,
26
+ } from './api.ts'
27
+ import type { LineageResponseVM, SearchResponseVM } from './dsh-tools/logic.ts'
28
+ import type { ProjectGroupVM } from './board/project-view-logic.ts'
29
+
30
+ /** Body of `GET <prefix>/projects` (host: routes.ts handleProjects). */
31
+ export interface ProjectsResponseVM {
32
+ groups: ProjectGroupVM[]
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Bounded same-origin GET (api.ts `request` is module-private; this is the
37
+ // same discipline detail/transport.ts uses: timeout, external abort,
38
+ // ApiError taxonomy).
39
+ // ---------------------------------------------------------------------------
40
+
41
+ const defaultSetTimeout = (fn: () => void, ms: number): TimerHandle =>
42
+ globalThis.setTimeout(fn, ms)
43
+
44
+ const defaultClearTimeout = (handle: TimerHandle): void => {
45
+ globalThis.clearTimeout(handle as ReturnType<typeof globalThis.setTimeout>)
46
+ }
47
+
48
+ const defaultCreateAbortController = (): AbortControllerLike => new AbortController()
49
+
50
+ function resolveFetch(opts: RequestOptions): FetchLike {
51
+ if (opts.fetch !== undefined) return opts.fetch
52
+ return globalThis.fetch as unknown as FetchLike
53
+ }
54
+
55
+ async function getJson(path: string, opts: RequestOptions): Promise<unknown> {
56
+ const doFetch = resolveFetch(opts)
57
+ const controller = (opts.createAbortController ?? defaultCreateAbortController)()
58
+ const setT = opts.setTimeout ?? defaultSetTimeout
59
+ const clearT = opts.clearTimeout ?? defaultClearTimeout
60
+
61
+ let timedOut = false
62
+ let externallyAborted = false
63
+ const timer = setT(() => {
64
+ timedOut = true
65
+ controller.abort()
66
+ }, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS)
67
+
68
+ const external = opts.signal
69
+ const onExternalAbort = (): void => {
70
+ externallyAborted = true
71
+ controller.abort()
72
+ }
73
+ if (external !== undefined) {
74
+ if (external.aborted) onExternalAbort()
75
+ else external.addEventListener('abort', onExternalAbort)
76
+ }
77
+
78
+ try {
79
+ let res: ResponseLike
80
+ try {
81
+ res = await doFetch(path, { method: 'GET', signal: controller.signal })
82
+ } catch (err) {
83
+ if (timedOut) throw new ApiError('timeout', 'request_timeout', null, err)
84
+ if (externallyAborted) throw new ApiError('aborted', 'request_aborted', null, err)
85
+ throw new ApiError('network', 'network_error', null, err)
86
+ }
87
+ if (!res.ok) {
88
+ let reason = `http_${res.status}`
89
+ try {
90
+ const body = await res.json()
91
+ if (typeof body === 'object' && body !== null) {
92
+ const value = (body as Record<string, unknown>)['reason']
93
+ if (typeof value === 'string' && value !== '') reason = value
94
+ }
95
+ } catch {
96
+ // Non-JSON error body: the status-derived reason stands.
97
+ }
98
+ throw new ApiError('http', reason, res.status)
99
+ }
100
+ try {
101
+ return await res.json()
102
+ } catch (err) {
103
+ if (timedOut) throw new ApiError('timeout', 'request_timeout', null, err)
104
+ throw new ApiError('parse', 'invalid_json', res.status, err)
105
+ }
106
+ } finally {
107
+ clearT(timer)
108
+ if (external !== undefined) external.removeEventListener('abort', onExternalAbort)
109
+ }
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Public surface.
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /**
117
+ * `GET <prefix>/lineage/<id>` — dsh lineage trace. Degradation
118
+ * (sessionQuery absent / trace failed) is DATA: the host answers 200 with
119
+ * `{available:false, reason}`, so only transport/HTTP failures reject
120
+ * (e.g. 501 `fusion_not_wired` on a pre-M3 host).
121
+ */
122
+ export async function fetchLineage(
123
+ sessionId: string,
124
+ opts: RequestOptions = {},
125
+ ): Promise<LineageResponseVM> {
126
+ const path = `${API_PREFIX}/lineage/${encodeURIComponent(sessionId)}`
127
+ return (await getJson(path, opts)) as LineageResponseVM
128
+ }
129
+
130
+ /**
131
+ * `GET <prefix>/search?q=&project=&limit=` — cross-agent session search.
132
+ * At least one of `q` / `project` must be non-blank (the host answers 400
133
+ * `invalid_request` otherwise — callers gate before dialing). The response
134
+ * echoes the mode; `filter-only` is the honest degradation, not an error.
135
+ */
136
+ export async function fetchSearch(
137
+ opts: RequestOptions & { q?: string; project?: string | null; limit?: number } = {},
138
+ ): Promise<SearchResponseVM> {
139
+ const params = new URLSearchParams()
140
+ if (opts.q !== undefined && opts.q.trim() !== '') params.set('q', opts.q)
141
+ if (opts.project !== undefined && opts.project !== null && opts.project.trim() !== '') {
142
+ params.set('project', opts.project)
143
+ }
144
+ if (opts.limit !== undefined) params.set('limit', String(opts.limit))
145
+ return (await getJson(`${API_PREFIX}/search?${params.toString()}`, opts)) as SearchResponseVM
146
+ }
147
+
148
+ /** `GET <prefix>/projects` — cross-agent project groups. */
149
+ export async function fetchProjects(opts: RequestOptions = {}): Promise<ProjectsResponseVM> {
150
+ return (await getJson(`${API_PREFIX}/projects`, opts)) as ProjectsResponseVM
151
+ }
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Slot-facing React glue (T2.4): binds the {@link SidecarController} stores
3
+ * to the three presentational modules via `useSyncExternalStore` and hands
4
+ * back zero-prop components ready for slot registration. Factories close
5
+ * over the controller so subscribe/getSnapshot identities stay stable
6
+ * across renders (uSES resubscribes on identity change).
7
+ *
8
+ * The settings card entry additionally owns the staged-edit lifecycle over
9
+ * a bound `SettingsScope` (browser mirror of the host settings namespace):
10
+ * resolved values come from the scope snapshot, edits stage locally, save
11
+ * writes one complete top-level group per changed group (see
12
+ * settings-glue.ts for the write-granularity rationale), and success is
13
+ * judged by comparing the post-write snapshot against the staged target —
14
+ * `scope.set` settles without rejecting even when the host declines the
15
+ * write (it recovers by reloading host state instead).
16
+ */
17
+
18
+ import { useEffect, useState, useSyncExternalStore } from 'react'
19
+ import type { ReactElement } from 'react'
20
+ import type { SettingsScope } from '@deepseek-ai/dsh-client-runtime/client'
21
+ import { Board } from './board/Board.tsx'
22
+ import { ProjectView } from './board/project-view.tsx'
23
+ import { SidecarWidget } from './widget.tsx'
24
+ import { SettingsCard, type SettingsCardValues, type SidecarDaemonStatus } from './settings-card.tsx'
25
+ import { countWorking, deriveWidgetConnection } from './board/logic.ts'
26
+ import type { BoardFilterState } from './board/logic.ts'
27
+ import type { SidecarController, SidecarViewState } from './controller.ts'
28
+ import type { InjectMode } from './inject/logic.ts'
29
+ import type { InjectActions } from './inject-glue.ts'
30
+ import { SidecarDetailView, type SidecarUiIntegration } from './detail-view.tsx'
31
+ import { findCardHint, type DetailHeaderHint } from './detail-glue.ts'
32
+ import { findProjectSessionHint, type ProjectsStore } from './project-glue.ts'
33
+ import { detailErrorText } from './detail/logic.ts'
34
+ import { t } from './locales/index.ts'
35
+ import css from './detail-view.module.css'
36
+ import {
37
+ DEFAULT_CONFIG_VIEW,
38
+ cardValuesEqual,
39
+ configToValues,
40
+ diffGroups,
41
+ type SidecarConfigView,
42
+ } from './settings-glue.ts'
43
+
44
+ /**
45
+ * What the board tab needs to host the inject panel (S5 wiring, T4.9).
46
+ * Since T5.10b the panel opens from the detail view's 注入 button; this
47
+ * shape rides {@link SidecarUiIntegration.inject} unchanged.
48
+ */
49
+ export interface BoardInjectIntegration {
50
+ /** onPrepare/onExecute over the action transport (inject-glue.ts). */
51
+ actions: InjectActions
52
+ /**
53
+ * Late-bound `inject.default-mode` reader: the settings scope resolves
54
+ * after the tab mounts, so the value is read at panel-open time.
55
+ */
56
+ getDefaultMode: () => InjectMode
57
+ }
58
+
59
+ /** Board-tab main views (detail is an overlay route on top of either). */
60
+ type MainView = 'board' | 'projects'
61
+
62
+ /**
63
+ * Project-correlation view bound to its store: refresh on entry, then
64
+ * throttled SSE-driven refreshes for as long as the view is on screen.
65
+ */
66
+ function ProjectsContainer(props: {
67
+ controller: SidecarController
68
+ store: ProjectsStore
69
+ onSelectSession: (sessionId: string) => void
70
+ }): ReactElement {
71
+ const { controller, store } = props
72
+ useEffect(() => {
73
+ void store.refresh()
74
+ return controller.subscribe(() => { store.notifySnapshot() })
75
+ }, [controller, store])
76
+ const state = useSyncExternalStore(store.subscribe, store.getState, store.getState)
77
+ return (
78
+ <ProjectView
79
+ groups={state.groups}
80
+ loading={state.loading}
81
+ error={state.error === null ? null : detailErrorText(state.error)}
82
+ onSelectSession={props.onSelectSession}
83
+ />
84
+ )
85
+ }
86
+
87
+ /**
88
+ * Cross-agent board tab (the "Sidecar" conversation tab), since T5.10b the
89
+ * shell of the whole M3 information architecture (design §5.1):
90
+ *
91
+ * - view 1: the session board, with a 「会话看板 / 项目视图」 switcher
92
+ * (ProjectView over `GET projects`);
93
+ * - view 2: clicking a session card in EITHER view routes to the full-tab
94
+ * session-detail view (timeline + 注入 + AI 分析 + dsh 谱系/检索);
95
+ * detail-internal jumps (lineage nodes, search hits) re-route in place;
96
+ * - view 3: the M2 inject panel opens as a modal from the detail view.
97
+ *
98
+ * Without an integration the board renders read-only and inert (no detail
99
+ * routing) — the M1 degradation posture.
100
+ */
101
+ export function createBoardTab(
102
+ controller: SidecarController,
103
+ integration?: SidecarUiIntegration,
104
+ ): () => ReactElement {
105
+ const subscribe = (cb: () => void): (() => void) => controller.subscribe(cb)
106
+ const getState = (): SidecarViewState => controller.getState()
107
+ const getFilters = (): BoardFilterState => controller.getFilters()
108
+
109
+ return function SidecarBoardTab(): ReactElement {
110
+ // Third argument (server snapshot) keeps the components renderable under
111
+ // react-dom/server (DOM-level verification harness); same source.
112
+ const state = useSyncExternalStore(subscribe, getState, getState)
113
+ const filters = useSyncExternalStore(subscribe, getFilters, getFilters)
114
+ const [mainView, setMainView] = useState<MainView>('board')
115
+ const [detail, setDetail] = useState<{ id: string; hint: DetailHeaderHint | null } | null>(
116
+ null,
117
+ )
118
+ // One ProjectsStore per tab mount, created lazily with the integration
119
+ // seam; state survives board↔projects↔detail switches within the tab.
120
+ const [projectsStore] = useState<ProjectsStore | null>(
121
+ () => integration?.createProjectsStore() ?? null,
122
+ )
123
+ useEffect(() => () => { projectsStore?.dispose() }, [projectsStore])
124
+
125
+ const openDetail = (sessionId: string): void => {
126
+ if (integration === undefined) return
127
+ const hint =
128
+ findCardHint(state.sessions, sessionId) ??
129
+ (projectsStore !== null
130
+ ? findProjectSessionHint(projectsStore.getState().groups, sessionId)
131
+ : null)
132
+ setDetail({ id: sessionId, hint })
133
+ }
134
+
135
+ if (integration !== undefined && detail !== null) {
136
+ return (
137
+ <SidecarDetailView
138
+ // key remounts per session: fresh stores, no state bleed on jumps.
139
+ key={detail.id}
140
+ sessionId={detail.id}
141
+ hint={detail.hint}
142
+ controller={controller}
143
+ integration={integration}
144
+ onClose={() => { setDetail(null) }}
145
+ onSelectSession={openDetail}
146
+ />
147
+ )
148
+ }
149
+
150
+ return (
151
+ <>
152
+ <div className={css['switcherBar']} data-testid="agent-sidecar-view-switcher">
153
+ <button
154
+ type="button"
155
+ className={css['switcherButton']}
156
+ data-active={mainView === 'board' || undefined}
157
+ onClick={() => { setMainView('board') }}
158
+ >
159
+ {t('board.viewBoard')}
160
+ </button>
161
+ <button
162
+ type="button"
163
+ className={css['switcherButton']}
164
+ data-active={mainView === 'projects' || undefined}
165
+ onClick={() => { setMainView('projects') }}
166
+ >
167
+ {t('board.viewProjects')}
168
+ </button>
169
+ </div>
170
+ {mainView === 'projects' && projectsStore !== null
171
+ ? (
172
+ <ProjectsContainer
173
+ controller={controller}
174
+ store={projectsStore}
175
+ onSelectSession={openDetail}
176
+ />
177
+ )
178
+ : (
179
+ <Board
180
+ daemonState={state.daemonState}
181
+ {...state.daemonDetail !== undefined ? { daemonDetail: state.daemonDetail } : {}}
182
+ streamHealth={state.streamHealth}
183
+ lastReconcileAtMs={state.lastReconcileAtMs}
184
+ sessions={state.sessions}
185
+ filters={filters}
186
+ onFiltersChange={(next) => {
187
+ controller.setFilters(next)
188
+ }}
189
+ onRefresh={() => {
190
+ void controller.refresh()
191
+ }}
192
+ onSelectSession={openDetail}
193
+ />
194
+ )}
195
+ </>
196
+ )
197
+ }
198
+ }
199
+
200
+ /** Footer connection dot + working counter bound to the controller. */
201
+ export function createFooterWidget(controller: SidecarController): () => ReactElement {
202
+ const subscribe = (cb: () => void): (() => void) => controller.subscribe(cb)
203
+ const getState = (): SidecarViewState => controller.getState()
204
+ return function SidecarFooterWidget(): ReactElement {
205
+ const state = useSyncExternalStore(subscribe, getState, getState)
206
+ return (
207
+ <SidecarWidget
208
+ connection={deriveWidgetConnection(state.daemonState, state.streamHealth)}
209
+ workingCount={countWorking(state.sessions)}
210
+ />
211
+ )
212
+ }
213
+ }
214
+
215
+ /** Fallback card values while the scope snapshot is not ready. */
216
+ const FALLBACK_VALUES: SettingsCardValues = configToValues(DEFAULT_CONFIG_VIEW)
217
+
218
+ /**
219
+ * Settings card bound to the controller (daemon status row) and to the
220
+ * namespace scope (values + persistence). See the module doc for the
221
+ * staged-edit / save-verification contract.
222
+ */
223
+ export function createSettingsCardEntry(
224
+ controller: SidecarController,
225
+ scope: SettingsScope<SidecarConfigView>,
226
+ ): () => ReactElement {
227
+ const subscribeState = (cb: () => void): (() => void) => controller.subscribe(cb)
228
+ const getState = (): SidecarViewState => controller.getState()
229
+ const subscribeScope = (cb: () => void): (() => void) => scope.subscribe(cb)
230
+ const getSnapshot = (): ReturnType<typeof scope.getSnapshot> => scope.getSnapshot()
231
+
232
+ return function SidecarSettingsCardEntry(): ReactElement {
233
+ const snapshot = useSyncExternalStore(subscribeScope, getSnapshot, getSnapshot)
234
+ const state = useSyncExternalStore(subscribeState, getState, getState)
235
+ const [staged, setStaged] = useState<Partial<SettingsCardValues>>({})
236
+ const [saving, setSaving] = useState(false)
237
+ const [saveFailed, setSaveFailed] = useState(false)
238
+
239
+ const resolved =
240
+ snapshot.value !== undefined ? configToValues(snapshot.value) : FALLBACK_VALUES
241
+ const values: SettingsCardValues = { ...resolved, ...staged }
242
+ const writable =
243
+ snapshot.status === 'ready' && snapshot.writable && snapshot.mode === 'host'
244
+ const dirty = !cardValuesEqual(values, resolved)
245
+
246
+ const onChange = <K extends keyof SettingsCardValues>(
247
+ field: K,
248
+ value: SettingsCardValues[K],
249
+ ): void => {
250
+ setSaveFailed(false)
251
+ setStaged((prev) => {
252
+ const next = { ...prev }
253
+ if (resolved[field] === value) delete next[field]
254
+ else next[field] = value
255
+ return next
256
+ })
257
+ }
258
+
259
+ const onSave = (): void => {
260
+ const target = { ...resolved, ...staged }
261
+ setSaving(true)
262
+ setSaveFailed(false)
263
+ void (async () => {
264
+ try {
265
+ for (const { group, patch } of diffGroups(resolved, target)) {
266
+ await scope.set(group, patch)
267
+ }
268
+ const after = scope.getSnapshot().value
269
+ if (after !== undefined && cardValuesEqual(configToValues(after), target)) {
270
+ setStaged({})
271
+ } else {
272
+ setSaveFailed(true)
273
+ }
274
+ } catch (err) {
275
+ console.error('agent-sidecar: settings save failed', err)
276
+ setSaveFailed(true)
277
+ } finally {
278
+ setSaving(false)
279
+ }
280
+ })()
281
+ }
282
+
283
+ const daemon: SidecarDaemonStatus = {
284
+ state: state.daemonState,
285
+ ...state.lastPing !== null
286
+ ? { pid: state.lastPing.pid, version: state.lastPing.version }
287
+ : {},
288
+ }
289
+
290
+ return (
291
+ <SettingsCard
292
+ values={values}
293
+ onChange={onChange}
294
+ onSave={onSave}
295
+ onDiscard={() => {
296
+ setStaged({})
297
+ setSaveFailed(false)
298
+ }}
299
+ writable={writable}
300
+ dirty={dirty}
301
+ saving={saving}
302
+ saveFailed={saveFailed}
303
+ daemon={daemon}
304
+ />
305
+ )
306
+ }
307
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Project-correlation data glue (T5.10b): a framework-free store feeding
3
+ * the controlled ProjectView with `GET projects` wire groups. Derivation
4
+ * (normalize/merge/sort) stays in board/project-view-logic.ts — this store
5
+ * owns transport orchestration and refresh throttling only.
6
+ *
7
+ * Refresh model: an explicit `refresh()` on view entry, plus SSE-driven
8
+ * `notifySnapshot()` refreshes throttled to {@link DEFAULT_MIN_REFRESH_MS}
9
+ * (state frames arrive per board mutation; the projects endpoint recomputes
10
+ * groups per call, so hammering it buys nothing). Stale groups stay
11
+ * rendered through failed refreshes (honest banner, never a blank).
12
+ *
13
+ * Same store discipline as controller.ts: subscribe/getState for
14
+ * `useSyncExternalStore`, immutable snapshots, dispose() = late no-ops.
15
+ *
16
+ * @module
17
+ */
18
+
19
+ import { isApiError, type RequestOptions } from './api.ts'
20
+ import { fetchProjects, type ProjectsResponseVM } from './m3-transport.ts'
21
+ import type { ProjectGroupVM } from './board/project-view-logic.ts'
22
+ import type { DetailHeaderHint } from './detail-glue.ts'
23
+
24
+ /** Minimum spacing between SSE-triggered refreshes. */
25
+ export const DEFAULT_MIN_REFRESH_MS = 5_000
26
+
27
+ export interface ProjectsGlueState {
28
+ groups: ProjectGroupVM[]
29
+ /** True while a fetch is in flight AND nothing was loaded yet. */
30
+ loading: boolean
31
+ /** Machine reason code of the last failure, or null. */
32
+ error: string | null
33
+ /** Epoch ms of the last successful load; null before the first one. */
34
+ loadedAt: number | null
35
+ }
36
+
37
+ export interface ProjectsStoreOptions {
38
+ fetchProjectsFn?: (opts?: RequestOptions) => Promise<ProjectsResponseVM>
39
+ minRefreshMs?: number
40
+ /** Clock injection for throttling tests. */
41
+ now?: () => number
42
+ }
43
+
44
+ export class ProjectsStore {
45
+ private state: ProjectsGlueState = { groups: [], loading: false, error: null, loadedAt: null }
46
+ private readonly listeners = new Set<() => void>()
47
+ private readonly fetchProjectsFn: NonNullable<ProjectsStoreOptions['fetchProjectsFn']>
48
+ private readonly minRefreshMs: number
49
+ private readonly now: () => number
50
+ private disposed = false
51
+ private inFlight = false
52
+ private lastAttemptAt: number | null = null
53
+
54
+ constructor(options: ProjectsStoreOptions = {}) {
55
+ this.fetchProjectsFn = options.fetchProjectsFn ?? fetchProjects
56
+ this.minRefreshMs = options.minRefreshMs ?? DEFAULT_MIN_REFRESH_MS
57
+ this.now = options.now ?? Date.now
58
+ }
59
+
60
+ subscribe = (fn: () => void): (() => void) => {
61
+ this.listeners.add(fn)
62
+ return () => { this.listeners.delete(fn) }
63
+ }
64
+
65
+ getState = (): ProjectsGlueState => this.state
66
+
67
+ private setState(patch: Partial<ProjectsGlueState>): void {
68
+ if (this.disposed) return
69
+ this.state = { ...this.state, ...patch }
70
+ for (const fn of [...this.listeners]) fn()
71
+ }
72
+
73
+ /** Fetch the groups now (deduped while one call is in flight). */
74
+ async refresh(): Promise<void> {
75
+ if (this.disposed || this.inFlight) return
76
+ this.inFlight = true
77
+ this.lastAttemptAt = this.now()
78
+ if (this.state.loadedAt === null) this.setState({ loading: true, error: null })
79
+ try {
80
+ const body = await this.fetchProjectsFn()
81
+ if (this.disposed) return
82
+ this.setState({
83
+ groups: body.groups,
84
+ loading: false,
85
+ error: null,
86
+ loadedAt: this.now(),
87
+ })
88
+ } catch (err) {
89
+ if (this.disposed) return
90
+ // Previously loaded groups stay on screen; the view banners the code.
91
+ this.setState({
92
+ loading: false,
93
+ error: isApiError(err) ? err.reason : 'network_error',
94
+ })
95
+ } finally {
96
+ this.inFlight = false
97
+ }
98
+ }
99
+
100
+ /** SSE `state` frame hook: throttled refresh. */
101
+ notifySnapshot(): void {
102
+ if (this.disposed || this.inFlight) return
103
+ if (this.lastAttemptAt !== null && this.now() - this.lastAttemptAt < this.minRefreshMs) return
104
+ void this.refresh()
105
+ }
106
+
107
+ dispose(): void {
108
+ this.disposed = true
109
+ this.listeners.clear()
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Find a session inside loaded project groups → header hint for the
115
+ * detail view (project rows carry no board card), or null when unknown.
116
+ */
117
+ export function findProjectSessionHint(
118
+ groups: readonly ProjectGroupVM[],
119
+ sessionId: string,
120
+ ): DetailHeaderHint | null {
121
+ for (const group of groups) {
122
+ for (const session of group.sessions) {
123
+ if (session.sessionId === sessionId) {
124
+ return {
125
+ agent: session.agent,
126
+ title: session.title,
127
+ project: group.project,
128
+ status: session.status,
129
+ }
130
+ }
131
+ }
132
+ }
133
+ return null
134
+ }