@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,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
|
+
}
|