@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,79 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Detail-view container + board-tab view-switcher chrome (T5.10b, design
|
|
3
|
+
* §5.1): every color rides a --dsw-alias-* design token with a neutral
|
|
4
|
+
* fallback. Same posture as board.module.css / dsh-tools.module.css.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/* ------------------------------------------------ board-tab view switcher */
|
|
8
|
+
|
|
9
|
+
.switcherBar {
|
|
10
|
+
display: flex;
|
|
11
|
+
align-items: center;
|
|
12
|
+
gap: 4px;
|
|
13
|
+
padding: 8px 12px 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.switcherButton {
|
|
17
|
+
font-size: 12px;
|
|
18
|
+
padding: 3px 10px;
|
|
19
|
+
border-radius: 999px;
|
|
20
|
+
border: 1px solid transparent;
|
|
21
|
+
background: transparent;
|
|
22
|
+
color: var(--dsw-alias-label-secondary, #57606a);
|
|
23
|
+
cursor: pointer;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.switcherButton:hover {
|
|
27
|
+
background: var(--dsw-alias-bg-layer-1, rgba(0, 0, 0, 0.02));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.switcherButton[data-active='true'] {
|
|
31
|
+
font-weight: 600;
|
|
32
|
+
color: var(--dsw-alias-label-primary, #1f2328);
|
|
33
|
+
background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, 0.04));
|
|
34
|
+
border-color: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* --------------------------------------------------- detail-view chrome */
|
|
38
|
+
|
|
39
|
+
.detailRoot {
|
|
40
|
+
display: flex;
|
|
41
|
+
flex-direction: column;
|
|
42
|
+
gap: 12px;
|
|
43
|
+
min-width: 0;
|
|
44
|
+
padding: 12px;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.actionsRow {
|
|
48
|
+
display: flex;
|
|
49
|
+
align-items: center;
|
|
50
|
+
gap: 8px;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.actionButton {
|
|
54
|
+
font-size: 12px;
|
|
55
|
+
padding: 3px 12px;
|
|
56
|
+
border-radius: 6px;
|
|
57
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
|
|
58
|
+
background: var(--dsw-alias-bg-layer-1, rgba(0, 0, 0, 0.02));
|
|
59
|
+
color: var(--dsw-alias-label-primary, #1f2328);
|
|
60
|
+
cursor: pointer;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.actionButton:hover {
|
|
64
|
+
background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, 0.04));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.actionButton:disabled {
|
|
68
|
+
opacity: 0.5;
|
|
69
|
+
cursor: not-allowed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* dsh deep-query tools section under the timeline. */
|
|
73
|
+
.toolsSection {
|
|
74
|
+
display: flex;
|
|
75
|
+
flex-direction: column;
|
|
76
|
+
gap: 16px;
|
|
77
|
+
border-top: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.08));
|
|
78
|
+
padding-top: 12px;
|
|
79
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-detail container (T5.10b, design §5.1 view 2): the full-tab
|
|
3
|
+
* detail surface opened by clicking a session card on the board or in the
|
|
4
|
+
* project view. Composes the M3 presentational components over their glue
|
|
5
|
+
* stores:
|
|
6
|
+
*
|
|
7
|
+
* - SessionDetail (timeline) ← DetailStore (fetchSessionDetail +
|
|
8
|
+
* fetchTimelinePage pagination + SSE-triggered listen refetch);
|
|
9
|
+
* - action row: 注入 (M2 InjectPanel as a modal, reused verbatim) and
|
|
10
|
+
* AI 分析 (AnalysisPanel over AnalysisStore; disabled with an honest
|
|
11
|
+
* hint while `analysis.enabled` is off);
|
|
12
|
+
* - dsh 会话专属区: LineageTree ← the DetailStore lineage slice (non-dsh
|
|
13
|
+
* sessions degrade client-side, no dialing) and SearchPanel ←
|
|
14
|
+
* SearchStore (full-text or filter-only degradation; a result click
|
|
15
|
+
* navigates the detail view to that session).
|
|
16
|
+
*
|
|
17
|
+
* The stores live in component state (one set per opened session id — the
|
|
18
|
+
* owner keys this component by session id) and are disposed on unmount.
|
|
19
|
+
* SSE coupling: the controller's subscribe seam notifies the DetailStore
|
|
20
|
+
* on every state frame (header refresh + listen-mode refetch trigger).
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { useEffect, useState, useSyncExternalStore } from 'react'
|
|
26
|
+
import type { ReactElement } from 'react'
|
|
27
|
+
import { SessionDetail } from './detail/SessionDetail.tsx'
|
|
28
|
+
import { LineageTree } from './dsh-tools/LineageTree.tsx'
|
|
29
|
+
import { SearchPanel } from './dsh-tools/SearchPanel.tsx'
|
|
30
|
+
import { AnalysisPanel } from './analysis/AnalysisPanel.tsx'
|
|
31
|
+
import { InjectPanel } from './inject/InjectPanel.tsx'
|
|
32
|
+
import { DetailStore, findCardHint, type DetailHeaderHint } from './detail-glue.ts'
|
|
33
|
+
import { SearchStore } from './search-glue.ts'
|
|
34
|
+
import { AnalysisStore } from './analysis-glue.ts'
|
|
35
|
+
import { ProjectsStore } from './project-glue.ts'
|
|
36
|
+
import type { SidecarController } from './controller.ts'
|
|
37
|
+
import type { InjectMode } from './inject/logic.ts'
|
|
38
|
+
import type { InjectActions } from './inject-glue.ts'
|
|
39
|
+
import { t } from './locales/index.ts'
|
|
40
|
+
import css from './detail-view.module.css'
|
|
41
|
+
import overlay from './inject/overlay.module.css'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Everything the integrated M3 surfaces need from the entry (index.ts
|
|
45
|
+
* builds one; mount.tsx and this container share it). Store factories are
|
|
46
|
+
* seams so tests/materialization can substitute injected transports.
|
|
47
|
+
*/
|
|
48
|
+
export interface SidecarUiIntegration {
|
|
49
|
+
/** M2 injection wiring; absent = injection not assembled (entry hidden). */
|
|
50
|
+
inject?: {
|
|
51
|
+
actions: InjectActions
|
|
52
|
+
getDefaultMode: () => InjectMode
|
|
53
|
+
}
|
|
54
|
+
/** Live `analysis.enabled` reader (settings scope box; default false). */
|
|
55
|
+
getAnalysisEnabled: () => boolean
|
|
56
|
+
createDetailStore: (sessionId: string, hint: DetailHeaderHint | null) => DetailStore
|
|
57
|
+
createSearchStore: () => SearchStore
|
|
58
|
+
createAnalysisStore: () => AnalysisStore
|
|
59
|
+
createProjectsStore: () => ProjectsStore
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Default integration over the real transports (analysis off until read). */
|
|
63
|
+
export function createDefaultIntegration(
|
|
64
|
+
base: Omit<
|
|
65
|
+
SidecarUiIntegration,
|
|
66
|
+
'createDetailStore' | 'createSearchStore' | 'createAnalysisStore' | 'createProjectsStore'
|
|
67
|
+
>,
|
|
68
|
+
): SidecarUiIntegration {
|
|
69
|
+
return {
|
|
70
|
+
...base,
|
|
71
|
+
createDetailStore: (sessionId, hint) => new DetailStore(sessionId, { hint }),
|
|
72
|
+
createSearchStore: () => new SearchStore(),
|
|
73
|
+
createAnalysisStore: () => new AnalysisStore(),
|
|
74
|
+
createProjectsStore: () => new ProjectsStore(),
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface SidecarDetailViewProps {
|
|
79
|
+
sessionId: string
|
|
80
|
+
/** Header seed from the opening surface (board card / project row). */
|
|
81
|
+
hint: DetailHeaderHint | null
|
|
82
|
+
controller: SidecarController
|
|
83
|
+
integration: SidecarUiIntegration
|
|
84
|
+
onClose: () => void
|
|
85
|
+
/** Provenance/search jump: navigate the detail view to another session. */
|
|
86
|
+
onSelectSession: (sessionId: string) => void
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The detail view. Owner remounts it per session id (`key={sessionId}`),
|
|
91
|
+
* so every store below is scoped to exactly one session.
|
|
92
|
+
*/
|
|
93
|
+
export function SidecarDetailView(props: SidecarDetailViewProps): ReactElement {
|
|
94
|
+
const { controller, integration, sessionId } = props
|
|
95
|
+
const [detailStore] = useState(() => integration.createDetailStore(sessionId, props.hint))
|
|
96
|
+
const [searchStore] = useState(() => integration.createSearchStore())
|
|
97
|
+
const [analysisStore] = useState(() => integration.createAnalysisStore())
|
|
98
|
+
const [injectOpen, setInjectOpen] = useState(false)
|
|
99
|
+
const [analysisOpen, setAnalysisOpen] = useState(false)
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
void detailStore.open()
|
|
103
|
+
// One notification per SSE state frame / poll settle: header refresh
|
|
104
|
+
// from the live board card + listen-mode newest-window refetch.
|
|
105
|
+
const unsubscribe = controller.subscribe(() => {
|
|
106
|
+
detailStore.notifySnapshot(findCardHint(controller.getState().sessions, sessionId))
|
|
107
|
+
})
|
|
108
|
+
return () => {
|
|
109
|
+
unsubscribe()
|
|
110
|
+
detailStore.dispose()
|
|
111
|
+
searchStore.dispose()
|
|
112
|
+
analysisStore.dispose()
|
|
113
|
+
}
|
|
114
|
+
}, [controller, detailStore, searchStore, analysisStore, sessionId])
|
|
115
|
+
|
|
116
|
+
const detail = useSyncExternalStore(
|
|
117
|
+
detailStore.subscribe, detailStore.getState, detailStore.getState)
|
|
118
|
+
const search = useSyncExternalStore(
|
|
119
|
+
searchStore.subscribe, searchStore.getState, searchStore.getState)
|
|
120
|
+
const analysis = useSyncExternalStore(
|
|
121
|
+
analysisStore.subscribe, analysisStore.getState, analysisStore.getState)
|
|
122
|
+
const view = useSyncExternalStore(
|
|
123
|
+
(cb) => controller.subscribe(cb),
|
|
124
|
+
() => controller.getState(),
|
|
125
|
+
() => controller.getState(),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
const analysisEnabled = integration.getAnalysisEnabled()
|
|
129
|
+
const injectIntegration = props.integration.inject
|
|
130
|
+
const closeInject = (): void => { setInjectOpen(false) }
|
|
131
|
+
const title = detail.header.title.trim()
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div className={css['detailRoot']} data-testid="agent-sidecar-detail-view">
|
|
135
|
+
<div className={css['actionsRow']}>
|
|
136
|
+
{injectIntegration !== undefined && (
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
className={css['actionButton']}
|
|
140
|
+
onClick={() => { setInjectOpen(true) }}
|
|
141
|
+
data-testid="agent-sidecar-detail-inject"
|
|
142
|
+
>
|
|
143
|
+
{t('detail.actions.inject')}
|
|
144
|
+
</button>
|
|
145
|
+
)}
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
className={css['actionButton']}
|
|
149
|
+
disabled={!analysisEnabled}
|
|
150
|
+
title={analysisEnabled ? undefined : t('detail.actions.analyzeDisabledHint')}
|
|
151
|
+
onClick={() => { setAnalysisOpen(true) }}
|
|
152
|
+
data-testid="agent-sidecar-detail-analyze"
|
|
153
|
+
>
|
|
154
|
+
{t('detail.actions.analyze')}
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<SessionDetail
|
|
159
|
+
sessionId={sessionId}
|
|
160
|
+
header={detail.header}
|
|
161
|
+
timeline={detail.timeline}
|
|
162
|
+
loading={detail.loading}
|
|
163
|
+
error={detail.error}
|
|
164
|
+
hasMore={detail.hasMore}
|
|
165
|
+
listening={detail.listening}
|
|
166
|
+
onLoadMore={() => { void detailStore.loadMore() }}
|
|
167
|
+
onToggleListen={() => { detailStore.toggleListen() }}
|
|
168
|
+
onClose={props.onClose}
|
|
169
|
+
/>
|
|
170
|
+
|
|
171
|
+
{analysisOpen && (
|
|
172
|
+
<AnalysisPanel
|
|
173
|
+
enabled={analysisEnabled}
|
|
174
|
+
state={analysis}
|
|
175
|
+
onStart={() => {
|
|
176
|
+
void analysisStore.start({ targetKind: 'session', targetId: sessionId })
|
|
177
|
+
}}
|
|
178
|
+
onFollowup={(question) => { void analysisStore.followup(question) }}
|
|
179
|
+
onStop={() => { void analysisStore.stop() }}
|
|
180
|
+
onClose={() => { setAnalysisOpen(false) }}
|
|
181
|
+
/>
|
|
182
|
+
)}
|
|
183
|
+
|
|
184
|
+
<div className={css['toolsSection']} data-testid="agent-sidecar-detail-tools">
|
|
185
|
+
<LineageTree
|
|
186
|
+
trace={detail.lineage.trace}
|
|
187
|
+
available={detail.lineage.available}
|
|
188
|
+
reason={detail.lineage.reason}
|
|
189
|
+
detail={detail.lineage.detail}
|
|
190
|
+
currentSessionId={sessionId}
|
|
191
|
+
onSelectSession={props.onSelectSession}
|
|
192
|
+
loading={detail.lineage.loading}
|
|
193
|
+
error={detail.lineage.error}
|
|
194
|
+
/>
|
|
195
|
+
<SearchPanel
|
|
196
|
+
query={search.query}
|
|
197
|
+
project={search.project}
|
|
198
|
+
mode={search.mode}
|
|
199
|
+
items={search.items}
|
|
200
|
+
loading={search.loading}
|
|
201
|
+
error={search.error}
|
|
202
|
+
onQueryChange={(query) => { searchStore.setQuery(query) }}
|
|
203
|
+
onSubmit={() => { void searchStore.submit() }}
|
|
204
|
+
onSelectSession={props.onSelectSession}
|
|
205
|
+
/>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
{injectIntegration !== undefined && injectOpen && (
|
|
209
|
+
<div className={overlay['backdrop']} role="presentation" onClick={closeInject}>
|
|
210
|
+
<div
|
|
211
|
+
className={overlay['dialog']}
|
|
212
|
+
role="dialog"
|
|
213
|
+
aria-modal="true"
|
|
214
|
+
onClick={(event) => { event.stopPropagation() }}
|
|
215
|
+
>
|
|
216
|
+
<InjectPanel
|
|
217
|
+
capability={{ inject: view.injectCapability }}
|
|
218
|
+
target={{
|
|
219
|
+
agent: detail.header.agent,
|
|
220
|
+
sessionId,
|
|
221
|
+
...(title !== '' ? { title } : {}),
|
|
222
|
+
}}
|
|
223
|
+
defaultMode={injectIntegration.getDefaultMode()}
|
|
224
|
+
onPrepare={injectIntegration.actions.onPrepare}
|
|
225
|
+
onExecute={injectIntegration.actions.onExecute}
|
|
226
|
+
onClose={closeInject}
|
|
227
|
+
/>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
)
|
|
233
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh lineage tree (design §5.1 view 2 dsh 会话专属区: 谱系树 + 溯源跳转).
|
|
3
|
+
*
|
|
4
|
+
* Fully controlled and presentational — the component does NO data
|
|
5
|
+
* fetching; the integration layer calls `GET <prefix>/lineage/<id>` (or
|
|
6
|
+
* short-circuits with `externalLineageFallback` for non-dsh sessions) and
|
|
7
|
+
* feeds the always-200 body straight into the props. `available:false` is
|
|
8
|
+
* rendered as an honest degradation card per reason (§5.3), never as an
|
|
9
|
+
* error and never faked into an empty tree.
|
|
10
|
+
*
|
|
11
|
+
* The only component-owned state is the collapse set of the tree rows; it
|
|
12
|
+
* resets whenever the traced target changes. Every derivation lives in
|
|
13
|
+
* ./logic.ts and is unit-tested there.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useState } from 'react'
|
|
17
|
+
import type { ReactElement } from 'react'
|
|
18
|
+
import css from './dsh-tools.module.css'
|
|
19
|
+
import {
|
|
20
|
+
deriveLineageView,
|
|
21
|
+
formatTemplate,
|
|
22
|
+
resolveJumpTarget,
|
|
23
|
+
visibleLineageNodes,
|
|
24
|
+
type LineageNodeVM,
|
|
25
|
+
type LineageTraceVM,
|
|
26
|
+
type LineageTreeVM,
|
|
27
|
+
} from './logic.ts'
|
|
28
|
+
import { DSH_TOOLS_STRINGS } from './strings.ts'
|
|
29
|
+
|
|
30
|
+
export interface LineageTreeProps {
|
|
31
|
+
/** Wire trace of `GET lineage/<id>` (null while degraded/empty). */
|
|
32
|
+
trace: LineageTraceVM | null
|
|
33
|
+
/** Wire `available` bit; false renders a degradation card, not an error. */
|
|
34
|
+
available: boolean
|
|
35
|
+
/** Wire degradation reason, or the client-side `not_dsh_session`. */
|
|
36
|
+
reason?: string | null
|
|
37
|
+
/** Wire degradation detail (trace_failed carries the engine error). */
|
|
38
|
+
detail?: string | null
|
|
39
|
+
/** The session the user is inspecting — highlighted, click is a no-op. */
|
|
40
|
+
currentSessionId: string | null
|
|
41
|
+
/** Provenance jump: click on any other node navigates to that session. */
|
|
42
|
+
onSelectSession: (sessionId: string) => void
|
|
43
|
+
loading: boolean
|
|
44
|
+
/** Transport/HTTP failure text from the integration, or null. */
|
|
45
|
+
error: string | null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const S = DSH_TOOLS_STRINGS.lineage
|
|
49
|
+
|
|
50
|
+
function TreeRow(props: {
|
|
51
|
+
node: LineageNodeVM
|
|
52
|
+
collapsed: boolean
|
|
53
|
+
currentSessionId: string | null
|
|
54
|
+
onToggle: (id: string) => void
|
|
55
|
+
onSelectSession: (sessionId: string) => void
|
|
56
|
+
}): ReactElement {
|
|
57
|
+
const { node, collapsed } = props
|
|
58
|
+
const jump = resolveJumpTarget(node.id, props.currentSessionId)
|
|
59
|
+
return (
|
|
60
|
+
<div
|
|
61
|
+
className={css['treeRow']}
|
|
62
|
+
style={{ paddingLeft: node.depth * 16 }}
|
|
63
|
+
data-testid="agent-sidecar-lineage-row"
|
|
64
|
+
data-role={node.role}
|
|
65
|
+
>
|
|
66
|
+
{node.hasChildren ? (
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
className={css['toggle']}
|
|
70
|
+
aria-expanded={!collapsed}
|
|
71
|
+
aria-label={collapsed ? S.expand : S.collapse}
|
|
72
|
+
onClick={() => props.onToggle(node.id)}
|
|
73
|
+
>
|
|
74
|
+
{collapsed ? '▸' : '▾'}
|
|
75
|
+
</button>
|
|
76
|
+
) : (
|
|
77
|
+
<span className={css['toggleSpacer']} aria-hidden />
|
|
78
|
+
)}
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
className={css['node']}
|
|
82
|
+
data-current={node.isCurrent ? 'true' : 'false'}
|
|
83
|
+
aria-current={node.isCurrent ? 'true' : undefined}
|
|
84
|
+
title={jump.kind === 'current' ? S.currentTitle : S.jumpTitle}
|
|
85
|
+
onClick={() => {
|
|
86
|
+
if (jump.kind === 'select') props.onSelectSession(jump.sessionId)
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<span className={css['nodeRole']}>{node.roleLabel}</span>
|
|
90
|
+
<span className={css['nodeId']} title={node.id}>
|
|
91
|
+
{node.shortId}
|
|
92
|
+
</span>
|
|
93
|
+
{node.isCurrent && (
|
|
94
|
+
<span className={css['nodeBadge']} data-kind="current">
|
|
95
|
+
{S.currentBadge}
|
|
96
|
+
</span>
|
|
97
|
+
)}
|
|
98
|
+
{node.live && (
|
|
99
|
+
<span className={css['nodeBadge']} data-kind="live">
|
|
100
|
+
{S.liveBadge}
|
|
101
|
+
</span>
|
|
102
|
+
)}
|
|
103
|
+
{!node.persisted && <span className={css['nodeBadge']}>{S.notPersistedBadge}</span>}
|
|
104
|
+
</button>
|
|
105
|
+
</div>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function Tree(props: {
|
|
110
|
+
tree: LineageTreeVM
|
|
111
|
+
currentSessionId: string | null
|
|
112
|
+
onSelectSession: (sessionId: string) => void
|
|
113
|
+
}): ReactElement {
|
|
114
|
+
const { tree } = props
|
|
115
|
+
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set())
|
|
116
|
+
// Collapse state belongs to one trace; a new target starts expanded.
|
|
117
|
+
const [seenTarget, setSeenTarget] = useState(tree.targetId)
|
|
118
|
+
if (seenTarget !== tree.targetId) {
|
|
119
|
+
setSeenTarget(tree.targetId)
|
|
120
|
+
setCollapsed(new Set())
|
|
121
|
+
}
|
|
122
|
+
const toggle = (id: string): void => {
|
|
123
|
+
setCollapsed((prev) => {
|
|
124
|
+
const next = new Set(prev)
|
|
125
|
+
if (next.has(id)) next.delete(id)
|
|
126
|
+
else next.add(id)
|
|
127
|
+
return next
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
return (
|
|
131
|
+
<>
|
|
132
|
+
{tree.incompleteNotice !== null && (
|
|
133
|
+
<div className={css['noticeBar']} role="status">
|
|
134
|
+
{tree.incompleteNotice}
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
<div className={css['tree']} role="tree" data-testid="agent-sidecar-lineage-tree">
|
|
138
|
+
{visibleLineageNodes(tree.nodes, collapsed).map((node) => (
|
|
139
|
+
<TreeRow
|
|
140
|
+
key={node.id}
|
|
141
|
+
node={node}
|
|
142
|
+
collapsed={collapsed.has(node.id)}
|
|
143
|
+
currentSessionId={props.currentSessionId}
|
|
144
|
+
onToggle={toggle}
|
|
145
|
+
onSelectSession={props.onSelectSession}
|
|
146
|
+
/>
|
|
147
|
+
))}
|
|
148
|
+
</div>
|
|
149
|
+
</>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The lineage panel. Pure render of `deriveLineageView` over the props. */
|
|
154
|
+
export function LineageTree(props: LineageTreeProps): ReactElement {
|
|
155
|
+
const view = deriveLineageView({
|
|
156
|
+
loading: props.loading,
|
|
157
|
+
error: props.error,
|
|
158
|
+
available: props.available,
|
|
159
|
+
reason: props.reason ?? null,
|
|
160
|
+
detail: props.detail ?? null,
|
|
161
|
+
trace: props.trace,
|
|
162
|
+
currentSessionId: props.currentSessionId,
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<section className={css['panel']} data-testid="agent-sidecar-lineage">
|
|
167
|
+
<div className={css['panelHead']}>
|
|
168
|
+
<span className={css['panelTitle']}>{S.title}</span>
|
|
169
|
+
{view.kind === 'tree' && (
|
|
170
|
+
<span className={css['panelCount']}>
|
|
171
|
+
{formatTemplate(S.nodeCount, { n: view.tree.nodeCount })}
|
|
172
|
+
</span>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
{view.kind === 'loading' && <div className={css['mutedLine']}>{view.text}</div>}
|
|
177
|
+
|
|
178
|
+
{view.kind === 'error' && (
|
|
179
|
+
<div className={css['errorCard']} role="alert">
|
|
180
|
+
{view.text}
|
|
181
|
+
{view.detail !== null && <span className={css['errorDetail']}>{view.detail}</span>}
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
|
|
185
|
+
{view.kind === 'degraded' && (
|
|
186
|
+
<div
|
|
187
|
+
className={css['degradeCard']}
|
|
188
|
+
data-reason={view.card.reason}
|
|
189
|
+
data-testid="agent-sidecar-lineage-degraded"
|
|
190
|
+
>
|
|
191
|
+
<span className={css['degradeTitle']}>{view.card.title}</span>
|
|
192
|
+
<span className={css['degradeBody']}>{view.card.body}</span>
|
|
193
|
+
{view.card.detail !== null && (
|
|
194
|
+
<span className={css['degradeDetail']}>{view.card.detail}</span>
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
|
|
199
|
+
{view.kind === 'empty' && <div className={css['mutedLine']}>{view.text}</div>}
|
|
200
|
+
|
|
201
|
+
{view.kind === 'tree' && (
|
|
202
|
+
<Tree
|
|
203
|
+
tree={view.tree}
|
|
204
|
+
currentSessionId={props.currentSessionId}
|
|
205
|
+
onSelectSession={props.onSelectSession}
|
|
206
|
+
/>
|
|
207
|
+
)}
|
|
208
|
+
</section>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-agent session search panel (design §4.e.4 / §5.1: dsh 全文检索,
|
|
3
|
+
* 外部会话标题/项目过滤降级).
|
|
4
|
+
*
|
|
5
|
+
* Fully controlled and presentational — the component does NO data
|
|
6
|
+
* fetching; the integration layer owns the query state, calls
|
|
7
|
+
* `GET <prefix>/search?q=&project=&limit=`, maps the response through
|
|
8
|
+
* `normalizeSearchItems` (./logic.ts) and feeds the rows plus the wire
|
|
9
|
+
* `mode` back in. `mode: 'filter-only'` renders the honest degradation
|
|
10
|
+
* bar (§5.3) — the panel keeps working as a title/project filter and
|
|
11
|
+
* never pretends full-text ran.
|
|
12
|
+
*
|
|
13
|
+
* Snippets arrive pre-split into highlight segments (React-safe, no HTML
|
|
14
|
+
* injection); full-text hits carry them, filter hits render without.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ReactElement } from 'react'
|
|
18
|
+
import css from './dsh-tools.module.css'
|
|
19
|
+
import {
|
|
20
|
+
deriveSearchView,
|
|
21
|
+
formatTemplate,
|
|
22
|
+
type SearchItemVM,
|
|
23
|
+
type SearchMode,
|
|
24
|
+
} from './logic.ts'
|
|
25
|
+
import { DSH_TOOLS_STRINGS } from './strings.ts'
|
|
26
|
+
|
|
27
|
+
export interface SearchPanelProps {
|
|
28
|
+
/** Controlled query text (owner persists/submits it). */
|
|
29
|
+
query: string
|
|
30
|
+
/** Active project filter echoed by the backend, if any. */
|
|
31
|
+
project?: string | null
|
|
32
|
+
/** Wire search mode; 'filter-only' shows the degradation bar. */
|
|
33
|
+
mode: SearchMode
|
|
34
|
+
/** Normalized result rows (owner maps the wire body via logic.ts). */
|
|
35
|
+
items: SearchItemVM[]
|
|
36
|
+
loading: boolean
|
|
37
|
+
/** Transport/HTTP failure text from the integration, or null. */
|
|
38
|
+
error: string | null
|
|
39
|
+
onQueryChange: (query: string) => void
|
|
40
|
+
onSubmit: () => void
|
|
41
|
+
/** Result click → open that session (same seam as the board cards). */
|
|
42
|
+
onSelectSession: (sessionId: string) => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const S = DSH_TOOLS_STRINGS.search
|
|
46
|
+
|
|
47
|
+
function ResultItem(props: {
|
|
48
|
+
item: SearchItemVM
|
|
49
|
+
onSelectSession: (sessionId: string) => void
|
|
50
|
+
}): ReactElement {
|
|
51
|
+
const { item } = props
|
|
52
|
+
return (
|
|
53
|
+
<button
|
|
54
|
+
type="button"
|
|
55
|
+
className={css['resultItem']}
|
|
56
|
+
onClick={() => props.onSelectSession(item.sessionId)}
|
|
57
|
+
data-testid="agent-sidecar-search-item"
|
|
58
|
+
>
|
|
59
|
+
<span className={css['resultHead']}>
|
|
60
|
+
<span className={css['resultAgent']}>{item.agent}</span>
|
|
61
|
+
<span className={css['resultTitle']} title={item.title}>
|
|
62
|
+
{item.titleLabel}
|
|
63
|
+
</span>
|
|
64
|
+
<span className={css['matchTag']} data-kind={item.matchedBy}>
|
|
65
|
+
{item.matchedByLabel}
|
|
66
|
+
</span>
|
|
67
|
+
</span>
|
|
68
|
+
<span className={css['resultMeta']}>
|
|
69
|
+
<span className={css['resultProject']} title={item.project}>
|
|
70
|
+
{item.project}
|
|
71
|
+
</span>
|
|
72
|
+
<span className={css['resultId']} title={item.sessionId}>
|
|
73
|
+
{item.shortId}
|
|
74
|
+
</span>
|
|
75
|
+
</span>
|
|
76
|
+
{item.snippet !== null && (
|
|
77
|
+
<span className={css['snippet']}>
|
|
78
|
+
{item.snippet.map((segment, index) =>
|
|
79
|
+
segment.highlight ? (
|
|
80
|
+
<mark key={index} className={css['snippetMark']}>
|
|
81
|
+
{segment.text}
|
|
82
|
+
</mark>
|
|
83
|
+
) : (
|
|
84
|
+
<span key={index}>{segment.text}</span>
|
|
85
|
+
),
|
|
86
|
+
)}
|
|
87
|
+
</span>
|
|
88
|
+
)}
|
|
89
|
+
</button>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The search panel. Pure render of `deriveSearchView` over the props. */
|
|
94
|
+
export function SearchPanel(props: SearchPanelProps): ReactElement {
|
|
95
|
+
const view = deriveSearchView({
|
|
96
|
+
loading: props.loading,
|
|
97
|
+
error: props.error,
|
|
98
|
+
mode: props.mode,
|
|
99
|
+
itemCount: props.items.length,
|
|
100
|
+
query: props.query,
|
|
101
|
+
})
|
|
102
|
+
const project = props.project ?? null
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<section className={css['panel']} data-testid="agent-sidecar-search">
|
|
106
|
+
<div className={css['panelHead']}>
|
|
107
|
+
<span className={css['panelTitle']}>{S.title}</span>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<form
|
|
111
|
+
className={css['searchForm']}
|
|
112
|
+
onSubmit={(ev) => {
|
|
113
|
+
ev.preventDefault()
|
|
114
|
+
props.onSubmit()
|
|
115
|
+
}}
|
|
116
|
+
>
|
|
117
|
+
<input
|
|
118
|
+
type="search"
|
|
119
|
+
className={css['searchInput']}
|
|
120
|
+
value={props.query}
|
|
121
|
+
placeholder={S.placeholder}
|
|
122
|
+
onChange={(ev) => props.onQueryChange(ev.target.value)}
|
|
123
|
+
/>
|
|
124
|
+
<button type="submit" className={css['searchSubmit']}>
|
|
125
|
+
{S.submit}
|
|
126
|
+
</button>
|
|
127
|
+
</form>
|
|
128
|
+
|
|
129
|
+
{project !== null && project !== '' && (
|
|
130
|
+
<span className={css['projectChip']} title={project}>
|
|
131
|
+
{formatTemplate(S.projectFilter, { project })}
|
|
132
|
+
</span>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
{view.notice !== null && (
|
|
136
|
+
<div
|
|
137
|
+
className={css['noticeBar']}
|
|
138
|
+
role="status"
|
|
139
|
+
data-testid="agent-sidecar-search-degraded"
|
|
140
|
+
>
|
|
141
|
+
{view.notice}
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
{view.body === 'loading' && <div className={css['mutedLine']}>{view.text}</div>}
|
|
146
|
+
|
|
147
|
+
{view.body === 'error' && (
|
|
148
|
+
<div className={css['errorCard']} role="alert">
|
|
149
|
+
{view.text}
|
|
150
|
+
{view.detail !== null && <span className={css['errorDetail']}>{view.detail}</span>}
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
|
|
154
|
+
{view.body === 'empty' && <div className={css['mutedLine']}>{view.text}</div>}
|
|
155
|
+
|
|
156
|
+
{view.body === 'results' && (
|
|
157
|
+
<div className={css['resultList']}>
|
|
158
|
+
{props.items.map((item) => (
|
|
159
|
+
<ResultItem
|
|
160
|
+
key={`${item.agent}:${item.sessionId}`}
|
|
161
|
+
item={item}
|
|
162
|
+
onSelectSession={props.onSelectSession}
|
|
163
|
+
/>
|
|
164
|
+
))}
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
</section>
|
|
168
|
+
)
|
|
169
|
+
}
|