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