@shendeguize/dsh-agent-sidecar 0.1.0 → 0.1.1
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/lib/client.js +874 -168
- package/lib/client.js.map +1 -1
- package/package.json +1 -1
- package/src/client/board/Board.tsx +168 -15
- package/src/client/board/board.module.css +100 -0
- package/src/client/board/logic.ts +99 -21
- package/src/client/board/project-view-logic.ts +9 -3
- package/src/client/board/project-view.module.css +18 -0
- package/src/client/board/project-view.tsx +27 -1
- package/src/client/board/strings.ts +29 -5
- package/src/client/controller.ts +27 -7
- package/src/client/detail/SessionDetail.tsx +177 -14
- package/src/client/detail/detail.module.css +105 -2
- package/src/client/detail/logic.ts +220 -29
- package/src/client/detail/strings.ts +13 -0
- package/src/client/detail-glue.ts +33 -0
- package/src/client/detail-view.module.css +36 -3
- package/src/client/detail-view.tsx +76 -28
- package/src/client/dsh-tools/dsh-tools.module.css +6 -4
- package/src/client/inject/InjectPanel.tsx +57 -4
- package/src/client/inject/inject.module.css +68 -67
- package/src/client/inject/logic.ts +38 -0
- package/src/client/inject/overlay.module.css +1 -1
- package/src/client/locales/en.ts +17 -2
- package/src/client/locales/zh.ts +17 -2
- package/src/client/mount.tsx +3 -3
- package/src/client/widget.tsx +37 -12
|
@@ -109,6 +109,30 @@ export function formatRelativeTime(thenMs: number, nowMs: number): string {
|
|
|
109
109
|
return formatTemplate(DETAIL_STRINGS.time.daysAgo, { n: Math.floor(delta / DAY_MS) })
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
function pad2(n: number): string {
|
|
113
|
+
return n < 10 ? `0${n}` : String(n)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Absolute short timestamp for the time column (UX-13): the same local
|
|
118
|
+
* calendar day renders「HH:mm」, anything older (or a different day)
|
|
119
|
+
* renders「MM-DD HH:mm」— so a column of same-age rows stays tellable
|
|
120
|
+
* apart, unlike the coarse relative buckets. The format rule is
|
|
121
|
+
* copy-implemented to match the board column (no cross-surface import);
|
|
122
|
+
* the full ISO timestamp stays in the hover title.
|
|
123
|
+
*/
|
|
124
|
+
export function formatEventTime(thenMs: number, nowMs: number): string {
|
|
125
|
+
if (!Number.isFinite(thenMs) || !Number.isFinite(nowMs)) return ''
|
|
126
|
+
const then = new Date(thenMs)
|
|
127
|
+
const now = new Date(nowMs)
|
|
128
|
+
const hhmm = `${pad2(then.getHours())}:${pad2(then.getMinutes())}`
|
|
129
|
+
const sameDay =
|
|
130
|
+
then.getFullYear() === now.getFullYear() &&
|
|
131
|
+
then.getMonth() === now.getMonth() &&
|
|
132
|
+
then.getDate() === now.getDate()
|
|
133
|
+
return sameDay ? hhmm : `${pad2(then.getMonth() + 1)}-${pad2(then.getDate())} ${hhmm}`
|
|
134
|
+
}
|
|
135
|
+
|
|
112
136
|
// ---------------------------------------------------------------------------
|
|
113
137
|
// Event-kind classification (icon + label).
|
|
114
138
|
// ---------------------------------------------------------------------------
|
|
@@ -137,26 +161,43 @@ const KIND_GLYPHS: Record<TimelineKindToken, string> = {
|
|
|
137
161
|
other: '•',
|
|
138
162
|
}
|
|
139
163
|
|
|
164
|
+
/** One path segment → family token, or null when it names no family. */
|
|
165
|
+
function segmentToken(segment: string): TimelineKindToken | null {
|
|
166
|
+
if (segment === 'user') return 'user'
|
|
167
|
+
if (segment === 'assistant') return 'assistant'
|
|
168
|
+
if (segment === 'thinking' || segment === 'reasoning') return 'thinking'
|
|
169
|
+
if (segment === 'tool_call' || segment === 'tool-call' || segment === 'toolcall') {
|
|
170
|
+
return 'toolCall'
|
|
171
|
+
}
|
|
172
|
+
if (segment === 'tool_result' || segment === 'tool-result' || segment === 'toolresult') {
|
|
173
|
+
return 'toolResult'
|
|
174
|
+
}
|
|
175
|
+
if (segment.startsWith('turn_') || segment === 'turn') return 'turn'
|
|
176
|
+
if (segment.startsWith('step_') || segment === 'step') return 'step'
|
|
177
|
+
if (segment === 'error') return 'error'
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
|
|
140
181
|
/**
|
|
141
|
-
* Map a raw event kind onto the glyph/label vocabulary. Covers
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
* slash-path types
|
|
145
|
-
*
|
|
182
|
+
* Map a raw event kind onto the glyph/label vocabulary. Covers the sidecar
|
|
183
|
+
* normalized kinds — user/assistant/thinking/tool_call/tool_result plus
|
|
184
|
+
* the turn_ and step_ prefixes (sidecar/model.py) — and dsh native
|
|
185
|
+
* slash-path types in BOTH orders: `message/user` style (family last) and
|
|
186
|
+
* the observed `user/message` / `assistant/chunk` / `turn/start` style
|
|
187
|
+
* (family first; live-data fact, UX-03 — without it the conversation
|
|
188
|
+
* filter would misfile real dsh user messages as protocol noise). The
|
|
189
|
+
* last segment stays authoritative; the first is only a fallback.
|
|
190
|
+
* Everything else is honestly 'other'.
|
|
146
191
|
*/
|
|
147
192
|
export function classifyKind(kind: string): TimelineKindToken {
|
|
148
193
|
const k = kind.trim().toLowerCase()
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
if (last
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
return 'toolResult'
|
|
194
|
+
const segments = k.split('/')
|
|
195
|
+
const last = segmentToken(segments[segments.length - 1] ?? k)
|
|
196
|
+
if (last !== null) return last
|
|
197
|
+
if (segments.length > 1) {
|
|
198
|
+
const first = segmentToken(segments[0] ?? '')
|
|
199
|
+
if (first !== null) return first
|
|
156
200
|
}
|
|
157
|
-
if (last.startsWith('turn_') || last === 'turn') return 'turn'
|
|
158
|
-
if (last.startsWith('step_') || last === 'step') return 'step'
|
|
159
|
-
if (last === 'error') return 'error'
|
|
160
201
|
return 'other'
|
|
161
202
|
}
|
|
162
203
|
|
|
@@ -500,18 +541,26 @@ export function applyListenPage(vm: TimelineVM, page: TimelinePageWire): Timelin
|
|
|
500
541
|
// Gap detection + row derivation.
|
|
501
542
|
// ---------------------------------------------------------------------------
|
|
502
543
|
|
|
503
|
-
/** One rendered
|
|
544
|
+
/** One rendered event row (extracted so aggregation can carry members). */
|
|
545
|
+
export interface TimelineEventRowVM {
|
|
546
|
+
type: 'event'
|
|
547
|
+
key: string
|
|
548
|
+
entry: TimelineEntryVM
|
|
549
|
+
/** Absolute short time label (see {@link formatEventTime}). */
|
|
550
|
+
timeLabel: string
|
|
551
|
+
/** Hover title: ISO timestamp + raw kind + origin (+ seq) + relative age. */
|
|
552
|
+
hoverTitle: string
|
|
553
|
+
/** True when this entry arrived in the latest listen merge. */
|
|
554
|
+
isNew: boolean
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* One rendered timeline row: a real event, an honesty gap marker, or an
|
|
559
|
+
* aggregated run of adjacent empty streaming chunks (UX-03 — the members
|
|
560
|
+
* are carried verbatim so the view can expand the run without data loss).
|
|
561
|
+
*/
|
|
504
562
|
export type TimelineRowVM =
|
|
505
|
-
|
|
|
506
|
-
type: 'event'
|
|
507
|
-
key: string
|
|
508
|
-
entry: TimelineEntryVM
|
|
509
|
-
relativeTime: string
|
|
510
|
-
/** Hover title: ISO timestamp + raw kind + origin (+ seq). */
|
|
511
|
-
hoverTitle: string
|
|
512
|
-
/** True when this entry arrived in the latest listen merge. */
|
|
513
|
-
isNew: boolean
|
|
514
|
-
}
|
|
563
|
+
| TimelineEventRowVM
|
|
515
564
|
| {
|
|
516
565
|
type: 'gap'
|
|
517
566
|
key: string
|
|
@@ -519,6 +568,22 @@ export type TimelineRowVM =
|
|
|
519
568
|
missingCount: number
|
|
520
569
|
label: string
|
|
521
570
|
}
|
|
571
|
+
| {
|
|
572
|
+
type: 'chunks'
|
|
573
|
+
key: string
|
|
574
|
+
/** Raw wire kind shared by every member of the run. */
|
|
575
|
+
kindRaw: string
|
|
576
|
+
count: number
|
|
577
|
+
/** 「N 个流式分块」 */
|
|
578
|
+
label: string
|
|
579
|
+
/** Time label of the newest member. */
|
|
580
|
+
timeLabel: string
|
|
581
|
+
hoverTitle: string
|
|
582
|
+
/** True when any member arrived in the latest listen merge. */
|
|
583
|
+
isNew: boolean
|
|
584
|
+
/** The collapsed rows, verbatim, for lossless expansion. */
|
|
585
|
+
members: TimelineEventRowVM[]
|
|
586
|
+
}
|
|
522
587
|
|
|
523
588
|
function isoOrEmpty(ts: number): string {
|
|
524
589
|
if (!Number.isFinite(ts)) return ''
|
|
@@ -529,11 +594,12 @@ function isoOrEmpty(ts: number): string {
|
|
|
529
594
|
}
|
|
530
595
|
}
|
|
531
596
|
|
|
532
|
-
function eventHoverTitle(entry: TimelineEntryVM): string {
|
|
597
|
+
function eventHoverTitle(entry: TimelineEntryVM, nowMs: number): string {
|
|
533
598
|
const parts = [isoOrEmpty(entry.ts), entry.kindRaw, entry.origin]
|
|
534
599
|
if (entry.seq !== null) {
|
|
535
600
|
parts.push(formatTemplate(DETAIL_STRINGS.timeline.seq, { n: entry.seq }))
|
|
536
601
|
}
|
|
602
|
+
parts.push(formatRelativeTime(entry.ts, nowMs))
|
|
537
603
|
return parts.filter((p) => p !== '').join(' · ')
|
|
538
604
|
}
|
|
539
605
|
|
|
@@ -572,14 +638,139 @@ export function buildTimelineRows(vm: TimelineVM, nowMs: number): TimelineRowVM[
|
|
|
572
638
|
type: 'event',
|
|
573
639
|
key: entry.key,
|
|
574
640
|
entry,
|
|
575
|
-
|
|
576
|
-
hoverTitle: eventHoverTitle(entry),
|
|
641
|
+
timeLabel: formatEventTime(entry.ts, nowMs),
|
|
642
|
+
hoverTitle: eventHoverTitle(entry, nowMs),
|
|
577
643
|
isNew: newKeys.has(entry.key),
|
|
578
644
|
})
|
|
579
645
|
}
|
|
580
646
|
return rows
|
|
581
647
|
}
|
|
582
648
|
|
|
649
|
+
// ---------------------------------------------------------------------------
|
|
650
|
+
// Kind filter + chunk-run aggregation (UX-03: signal over protocol noise).
|
|
651
|
+
// ---------------------------------------------------------------------------
|
|
652
|
+
|
|
653
|
+
/** Kind-filter modes; pure UI state owned by the component. */
|
|
654
|
+
export type TimelineFilterMode = 'conversation' | 'all'
|
|
655
|
+
|
|
656
|
+
/** The kinds that count as conversation (review UX-03 vocabulary). */
|
|
657
|
+
export const CONVERSATION_KINDS: ReadonlySet<TimelineKindToken> = new Set([
|
|
658
|
+
'user',
|
|
659
|
+
'assistant',
|
|
660
|
+
'error',
|
|
661
|
+
])
|
|
662
|
+
|
|
663
|
+
export interface FilteredRows {
|
|
664
|
+
rows: TimelineRowVM[]
|
|
665
|
+
/** Exactly how many event rows the filter removed (honest count). */
|
|
666
|
+
hiddenCount: number
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Kind filter over derived rows: 'conversation' keeps user/assistant/error
|
|
671
|
+
* events only; 'all' passes everything through. Gap markers ALWAYS stay —
|
|
672
|
+
* honesty rows are not noise and hiding them could fake a clean timeline.
|
|
673
|
+
* Runs BEFORE {@link aggregateChunkRows} (gaps are detected on the full
|
|
674
|
+
* entry list upstream, so filtering can never fabricate a gap).
|
|
675
|
+
*/
|
|
676
|
+
export function filterTimelineRows(
|
|
677
|
+
rows: readonly TimelineRowVM[],
|
|
678
|
+
mode: TimelineFilterMode,
|
|
679
|
+
): FilteredRows {
|
|
680
|
+
if (mode === 'all') return { rows: [...rows], hiddenCount: 0 }
|
|
681
|
+
const out: TimelineRowVM[] = []
|
|
682
|
+
let hiddenCount = 0
|
|
683
|
+
for (const row of rows) {
|
|
684
|
+
if (row.type === 'event' && !CONVERSATION_KINDS.has(row.entry.kind)) {
|
|
685
|
+
hiddenCount += 1
|
|
686
|
+
continue
|
|
687
|
+
}
|
|
688
|
+
out.push(row)
|
|
689
|
+
}
|
|
690
|
+
return { rows: out, hiddenCount }
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* True for a protocol streaming-chunk entry: an empty one-line summary and
|
|
695
|
+
* a chunk-flavored kind (`assistant/chunk` style, matched on the last
|
|
696
|
+
* slash segment). These are the rows that drowned real conversation in
|
|
697
|
+
* the walkthrough (18 of 38 rows, review UX-03).
|
|
698
|
+
*/
|
|
699
|
+
export function isStreamChunkEntry(entry: TimelineEntryVM): boolean {
|
|
700
|
+
if (entry.summary !== '') return false
|
|
701
|
+
const k = entry.kindRaw.trim().toLowerCase()
|
|
702
|
+
const last = k.includes('/') ? (k.split('/').pop() ?? k) : k
|
|
703
|
+
return last === 'chunk' || last.endsWith('_chunk') || last.endsWith('-chunk')
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Collapse each maximal run of ≥2 adjacent same-kind streaming-chunk rows
|
|
708
|
+
* into one 'chunks' row carrying the members verbatim (lossless — the view
|
|
709
|
+
* offers 展开). Gap markers and any non-chunk row break a run, so the
|
|
710
|
+
* aggregation can never paper over a seq discontinuity. Single chunks stay
|
|
711
|
+
* as plain rows (a 1-run header would add noise, not remove it).
|
|
712
|
+
*/
|
|
713
|
+
export function aggregateChunkRows(rows: readonly TimelineRowVM[]): TimelineRowVM[] {
|
|
714
|
+
const out: TimelineRowVM[] = []
|
|
715
|
+
let run: TimelineEventRowVM[] = []
|
|
716
|
+
|
|
717
|
+
const flush = (): void => {
|
|
718
|
+
if (run.length >= 2) {
|
|
719
|
+
const first = run[0]!
|
|
720
|
+
const last = run[run.length - 1]!
|
|
721
|
+
out.push({
|
|
722
|
+
type: 'chunks',
|
|
723
|
+
key: `chunks:${first.key}`,
|
|
724
|
+
kindRaw: first.entry.kindRaw,
|
|
725
|
+
count: run.length,
|
|
726
|
+
label: formatTemplate(DETAIL_STRINGS.timeline.chunkRun, { n: run.length }),
|
|
727
|
+
timeLabel: last.timeLabel,
|
|
728
|
+
hoverTitle: `${first.entry.kindRaw} ×${run.length}`,
|
|
729
|
+
isNew: run.some((r) => r.isNew),
|
|
730
|
+
members: run,
|
|
731
|
+
})
|
|
732
|
+
} else {
|
|
733
|
+
out.push(...run)
|
|
734
|
+
}
|
|
735
|
+
run = []
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
for (const row of rows) {
|
|
739
|
+
const chunk = row.type === 'event' && isStreamChunkEntry(row.entry)
|
|
740
|
+
if (chunk) {
|
|
741
|
+
const sameKind = run.length === 0 || run[run.length - 1]!.entry.kindRaw === row.entry.kindRaw
|
|
742
|
+
if (!sameKind) flush()
|
|
743
|
+
run.push(row as TimelineEventRowVM)
|
|
744
|
+
continue
|
|
745
|
+
}
|
|
746
|
+
flush()
|
|
747
|
+
out.push(row)
|
|
748
|
+
}
|
|
749
|
+
flush()
|
|
750
|
+
return out
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// ---------------------------------------------------------------------------
|
|
754
|
+
// Viewport landing rule (UX-04: open on the newest events).
|
|
755
|
+
// ---------------------------------------------------------------------------
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Whether the view should pin its scroll position to the newest rows:
|
|
759
|
+
* on the first non-empty render (initial landing — understanding the
|
|
760
|
+
* current context needs the latest events, review UX-04), and on every
|
|
761
|
+
* append while listen mode is on. Loading older history must never yank
|
|
762
|
+
* the viewport (`positioned` stays true after the first landing).
|
|
763
|
+
*/
|
|
764
|
+
export function shouldStickToLatest(input: {
|
|
765
|
+
entryCount: number
|
|
766
|
+
/** True once the initial landing already happened. */
|
|
767
|
+
positioned: boolean
|
|
768
|
+
listening: boolean
|
|
769
|
+
}): boolean {
|
|
770
|
+
if (input.entryCount === 0) return false
|
|
771
|
+
return !input.positioned || input.listening
|
|
772
|
+
}
|
|
773
|
+
|
|
583
774
|
// ---------------------------------------------------------------------------
|
|
584
775
|
// Segmented rendering bound (perf stopgap; no full virtualization, see task
|
|
585
776
|
// report — page sizes already bound growth, this bounds pathological cases).
|
|
@@ -20,6 +20,11 @@ export const DETAIL_STRINGS = {
|
|
|
20
20
|
listenOn: '监听中',
|
|
21
21
|
listenOff: '监听',
|
|
22
22
|
listenHint: '开启后新事件将实时追加并高亮',
|
|
23
|
+
refresh: '刷新',
|
|
24
|
+
refreshing: '刷新中…',
|
|
25
|
+
refreshHint: '手动拉取最新时间线窗口',
|
|
26
|
+
copyIdTitle: '点击复制会话 ID',
|
|
27
|
+
copied: '已复制',
|
|
23
28
|
untitled: '(无标题)',
|
|
24
29
|
unknownProject: '未知项目',
|
|
25
30
|
/** Design §5.3 / SKILL.md wording: statuses are inferred observations. */
|
|
@@ -58,6 +63,12 @@ export const DETAIL_STRINGS = {
|
|
|
58
63
|
gap: {
|
|
59
64
|
label: '缺口:可能有 {n} 条事件未捕获(256 队列上限或未持久化)',
|
|
60
65
|
},
|
|
66
|
+
/** Kind filter chips (UX-03): protocol noise hidden by default, honestly counted. */
|
|
67
|
+
filter: {
|
|
68
|
+
conversation: '只看对话',
|
|
69
|
+
all: '全部事件',
|
|
70
|
+
hiddenNotice: '已隐藏 {n} 条协议事件',
|
|
71
|
+
},
|
|
61
72
|
/** Timeline list chrome. */
|
|
62
73
|
timeline: {
|
|
63
74
|
loadMore: '加载更多历史',
|
|
@@ -69,6 +80,8 @@ export const DETAIL_STRINGS = {
|
|
|
69
80
|
seq: 'seq {n}',
|
|
70
81
|
hiddenNotice: '为保持流畅,较早的 {n} 条已折叠',
|
|
71
82
|
showAll: '全部显示',
|
|
83
|
+
/** Aggregated row for an adjacent run of empty streaming chunks (UX-03). */
|
|
84
|
+
chunkRun: '{n} 个流式分块',
|
|
72
85
|
},
|
|
73
86
|
/** Loading / empty / error body states. */
|
|
74
87
|
states: {
|
|
@@ -83,6 +83,8 @@ export interface DetailGlueState {
|
|
|
83
83
|
error: string | null
|
|
84
84
|
hasMore: boolean
|
|
85
85
|
listening: boolean
|
|
86
|
+
/** True while a manual newest-window refresh is in flight (UX-07). */
|
|
87
|
+
refreshing: boolean
|
|
86
88
|
/** True once the initial load succeeded (timeline usable). */
|
|
87
89
|
ready: boolean
|
|
88
90
|
lineage: LineageSliceState
|
|
@@ -137,6 +139,7 @@ export class DetailStore {
|
|
|
137
139
|
private paging = false
|
|
138
140
|
private listenInFlight = false
|
|
139
141
|
private listenQueued = false
|
|
142
|
+
private refreshInFlight = false
|
|
140
143
|
private lineageStarted = false
|
|
141
144
|
|
|
142
145
|
constructor(sessionId: string, options: DetailStoreOptions = {}) {
|
|
@@ -152,6 +155,7 @@ export class DetailStore {
|
|
|
152
155
|
error: null,
|
|
153
156
|
hasMore: false,
|
|
154
157
|
listening: false,
|
|
158
|
+
refreshing: false,
|
|
155
159
|
ready: false,
|
|
156
160
|
lineage: INITIAL_LINEAGE,
|
|
157
161
|
}
|
|
@@ -241,6 +245,35 @@ export class DetailStore {
|
|
|
241
245
|
if (listening) this.scheduleListenRefetch()
|
|
242
246
|
}
|
|
243
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Manual newest-window refetch with visible feedback (UX-07), also fired
|
|
250
|
+
* once after a delivered injection (UX-05 observation loop). Unlike the
|
|
251
|
+
* silent best-effort listen refetch, it reports in-flight state and
|
|
252
|
+
* surfaces a failure reason (rendered as the inline banner). Appended
|
|
253
|
+
* entries get the listen-merge highlight. Coalesced: at most one manual
|
|
254
|
+
* refresh in flight, extra calls are dropped.
|
|
255
|
+
*/
|
|
256
|
+
async refreshNewest(): Promise<void> {
|
|
257
|
+
if (this.disposed || !this.state.ready || this.refreshInFlight) return
|
|
258
|
+
this.refreshInFlight = true
|
|
259
|
+
this.setState({ refreshing: true, error: null })
|
|
260
|
+
try {
|
|
261
|
+
const page = await this.fetchPageFn(this.state.sessionId, {
|
|
262
|
+
...(this.listenLimit !== undefined ? { limit: this.listenLimit } : {}),
|
|
263
|
+
})
|
|
264
|
+
if (this.disposed) return
|
|
265
|
+
this.setState({
|
|
266
|
+
timeline: applyListenPage(this.state.timeline, page),
|
|
267
|
+
refreshing: false,
|
|
268
|
+
})
|
|
269
|
+
} catch (err) {
|
|
270
|
+
if (this.disposed) return
|
|
271
|
+
this.setState({ refreshing: false, error: reasonOf(err) })
|
|
272
|
+
} finally {
|
|
273
|
+
this.refreshInFlight = false
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
244
277
|
/**
|
|
245
278
|
* SSE `state` frame hook (one call per controller notification). Refreshes
|
|
246
279
|
* the header from the live board card when given, and in listen mode
|
|
@@ -41,6 +41,12 @@
|
|
|
41
41
|
flex-direction: column;
|
|
42
42
|
gap: 12px;
|
|
43
43
|
min-width: 0;
|
|
44
|
+
/* Own the scroll like the board/project roots do (UX-12): without a
|
|
45
|
+
* bounded height the inner timeline's overflow-y can never engage, and
|
|
46
|
+
* the UX-04 landing / listen-mode tail pinning would silently no-op. */
|
|
47
|
+
height: 100%;
|
|
48
|
+
overflow-y: auto;
|
|
49
|
+
box-sizing: border-box;
|
|
44
50
|
padding: 12px;
|
|
45
51
|
}
|
|
46
52
|
|
|
@@ -69,11 +75,38 @@
|
|
|
69
75
|
cursor: not-allowed;
|
|
70
76
|
}
|
|
71
77
|
|
|
72
|
-
/* dsh deep-query tools
|
|
78
|
+
/* dsh deep-query tools: collapsible segment between the actions row and
|
|
79
|
+
* the timeline (UX-09 — previously buried below a possibly very long
|
|
80
|
+
* list, outside the viewport on long sessions). */
|
|
73
81
|
.toolsSection {
|
|
74
82
|
display: flex;
|
|
75
83
|
flex-direction: column;
|
|
76
84
|
gap: 16px;
|
|
77
|
-
border
|
|
78
|
-
|
|
85
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.08));
|
|
86
|
+
border-radius: 8px;
|
|
87
|
+
padding: 8px 12px;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.toolsToggle {
|
|
91
|
+
align-self: flex-start;
|
|
92
|
+
display: inline-flex;
|
|
93
|
+
align-items: center;
|
|
94
|
+
gap: 6px;
|
|
95
|
+
font-size: 12px;
|
|
96
|
+
font-family: inherit;
|
|
97
|
+
color: var(--dsw-alias-label-secondary, #57606a);
|
|
98
|
+
background: transparent;
|
|
99
|
+
border: none;
|
|
100
|
+
padding: 0;
|
|
101
|
+
cursor: pointer;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.toolsToggle:hover {
|
|
105
|
+
color: var(--dsw-alias-label-primary, #1f2328);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.toolsToggleGlyph {
|
|
109
|
+
flex: none;
|
|
110
|
+
font-size: 10px;
|
|
111
|
+
color: var(--dsw-alias-label-tertiary, #6e7781);
|
|
79
112
|
}
|
|
@@ -34,7 +34,7 @@ import { SearchStore } from './search-glue.ts'
|
|
|
34
34
|
import { AnalysisStore } from './analysis-glue.ts'
|
|
35
35
|
import { ProjectsStore } from './project-glue.ts'
|
|
36
36
|
import type { SidecarController } from './controller.ts'
|
|
37
|
-
import type
|
|
37
|
+
import { isDeliveredResult, type InjectMode } from './inject/logic.ts'
|
|
38
38
|
import type { InjectActions } from './inject-glue.ts'
|
|
39
39
|
import { t } from './locales/index.ts'
|
|
40
40
|
import css from './detail-view.module.css'
|
|
@@ -97,6 +97,7 @@ export function SidecarDetailView(props: SidecarDetailViewProps): ReactElement {
|
|
|
97
97
|
const [analysisStore] = useState(() => integration.createAnalysisStore())
|
|
98
98
|
const [injectOpen, setInjectOpen] = useState(false)
|
|
99
99
|
const [analysisOpen, setAnalysisOpen] = useState(false)
|
|
100
|
+
const [toolsOpen, setToolsOpen] = useState(false)
|
|
100
101
|
|
|
101
102
|
useEffect(() => {
|
|
102
103
|
void detailStore.open()
|
|
@@ -130,6 +131,30 @@ export function SidecarDetailView(props: SidecarDetailViewProps): ReactElement {
|
|
|
130
131
|
const closeInject = (): void => { setInjectOpen(false) }
|
|
131
132
|
const title = detail.header.title.trim()
|
|
132
133
|
|
|
134
|
+
// UX-05 observation loop, part 1: a delivered execute refetches the
|
|
135
|
+
// newest timeline window at once, so closing the panel never lands on a
|
|
136
|
+
// stale pre-injection timeline. Wraps (not replaces) the integration
|
|
137
|
+
// callback — the two-phase flow and the owner's onDelivered hook (board
|
|
138
|
+
// snapshot refresh) stay untouched.
|
|
139
|
+
const injectActions: InjectActions | undefined =
|
|
140
|
+
injectIntegration === undefined
|
|
141
|
+
? undefined
|
|
142
|
+
: {
|
|
143
|
+
onPrepare: injectIntegration.actions.onPrepare,
|
|
144
|
+
onExecute: async (req) => {
|
|
145
|
+
const result = await injectIntegration.actions.onExecute(req)
|
|
146
|
+
if (isDeliveredResult(result)) void detailStore.refreshNewest()
|
|
147
|
+
return result
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// UX-05 part 2: the delivered result page offers「开启监听观察反应」—
|
|
152
|
+
// flip listen mode on (if off) and hand the view back to the timeline.
|
|
153
|
+
const observeReaction = (): void => {
|
|
154
|
+
if (!detailStore.getState().listening) detailStore.toggleListen()
|
|
155
|
+
closeInject()
|
|
156
|
+
}
|
|
157
|
+
|
|
133
158
|
return (
|
|
134
159
|
<div className={css['detailRoot']} data-testid="agent-sidecar-detail-view">
|
|
135
160
|
<div className={css['actionsRow']}>
|
|
@@ -155,6 +180,50 @@ export function SidecarDetailView(props: SidecarDetailViewProps): ReactElement {
|
|
|
155
180
|
</button>
|
|
156
181
|
</div>
|
|
157
182
|
|
|
183
|
+
{/* Collapsible dsh deep-query tools ABOVE the timeline (UX-09):
|
|
184
|
+
discoverable without scrolling past a long event list. */}
|
|
185
|
+
<div className={css['toolsSection']} data-testid="agent-sidecar-detail-tools">
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
className={css['toolsToggle']}
|
|
189
|
+
aria-expanded={toolsOpen}
|
|
190
|
+
onClick={() => { setToolsOpen((open) => !open) }}
|
|
191
|
+
>
|
|
192
|
+
<span className={css['toolsToggleGlyph']} aria-hidden>
|
|
193
|
+
{toolsOpen ? '▾' : '▸'}
|
|
194
|
+
</span>
|
|
195
|
+
{t('detail.tools.title')}
|
|
196
|
+
<span className={css['toolsToggleGlyph']}>
|
|
197
|
+
{toolsOpen ? t('detail.tools.hide') : t('detail.tools.show')}
|
|
198
|
+
</span>
|
|
199
|
+
</button>
|
|
200
|
+
{toolsOpen && (
|
|
201
|
+
<>
|
|
202
|
+
<LineageTree
|
|
203
|
+
trace={detail.lineage.trace}
|
|
204
|
+
available={detail.lineage.available}
|
|
205
|
+
reason={detail.lineage.reason}
|
|
206
|
+
detail={detail.lineage.detail}
|
|
207
|
+
currentSessionId={sessionId}
|
|
208
|
+
onSelectSession={props.onSelectSession}
|
|
209
|
+
loading={detail.lineage.loading}
|
|
210
|
+
error={detail.lineage.error}
|
|
211
|
+
/>
|
|
212
|
+
<SearchPanel
|
|
213
|
+
query={search.query}
|
|
214
|
+
project={search.project}
|
|
215
|
+
mode={search.mode}
|
|
216
|
+
items={search.items}
|
|
217
|
+
loading={search.loading}
|
|
218
|
+
error={search.error}
|
|
219
|
+
onQueryChange={(query) => { searchStore.setQuery(query) }}
|
|
220
|
+
onSubmit={() => { void searchStore.submit() }}
|
|
221
|
+
onSelectSession={props.onSelectSession}
|
|
222
|
+
/>
|
|
223
|
+
</>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
|
|
158
227
|
<SessionDetail
|
|
159
228
|
sessionId={sessionId}
|
|
160
229
|
header={detail.header}
|
|
@@ -163,8 +232,10 @@ export function SidecarDetailView(props: SidecarDetailViewProps): ReactElement {
|
|
|
163
232
|
error={detail.error}
|
|
164
233
|
hasMore={detail.hasMore}
|
|
165
234
|
listening={detail.listening}
|
|
235
|
+
refreshing={detail.refreshing}
|
|
166
236
|
onLoadMore={() => { void detailStore.loadMore() }}
|
|
167
237
|
onToggleListen={() => { detailStore.toggleListen() }}
|
|
238
|
+
onRefresh={() => { void detailStore.refreshNewest() }}
|
|
168
239
|
onClose={props.onClose}
|
|
169
240
|
/>
|
|
170
241
|
|
|
@@ -181,31 +252,7 @@ export function SidecarDetailView(props: SidecarDetailViewProps): ReactElement {
|
|
|
181
252
|
/>
|
|
182
253
|
)}
|
|
183
254
|
|
|
184
|
-
|
|
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 && (
|
|
255
|
+
{injectIntegration !== undefined && injectActions !== undefined && injectOpen && (
|
|
209
256
|
<div className={overlay['backdrop']} role="presentation" onClick={closeInject}>
|
|
210
257
|
<div
|
|
211
258
|
className={overlay['dialog']}
|
|
@@ -221,9 +268,10 @@ export function SidecarDetailView(props: SidecarDetailViewProps): ReactElement {
|
|
|
221
268
|
...(title !== '' ? { title } : {}),
|
|
222
269
|
}}
|
|
223
270
|
defaultMode={injectIntegration.getDefaultMode()}
|
|
224
|
-
onPrepare={
|
|
225
|
-
onExecute={
|
|
271
|
+
onPrepare={injectActions.onPrepare}
|
|
272
|
+
onExecute={injectActions.onExecute}
|
|
226
273
|
onClose={closeInject}
|
|
274
|
+
onObserve={observeReaction}
|
|
227
275
|
/>
|
|
228
276
|
</div>
|
|
229
277
|
</div>
|
|
@@ -169,10 +169,12 @@
|
|
|
169
169
|
outline-offset: 1px;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
/* Current-session highlight (the row the user is inspecting).
|
|
172
|
+
/* Current-session highlight (the row the user is inspecting). Brand tint
|
|
173
|
+
* rides the real brand-primary token — brand-secondary/tertiary do not
|
|
174
|
+
* exist in the dsh token table (UX-15). */
|
|
173
175
|
.node[data-current='true'] {
|
|
174
176
|
border-color: var(--dsw-alias-brand-primary, #4d6bfe);
|
|
175
|
-
background: var(--dsw-alias-brand-
|
|
177
|
+
background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4d6bfe) 8%, transparent);
|
|
176
178
|
cursor: default;
|
|
177
179
|
}
|
|
178
180
|
|
|
@@ -333,7 +335,7 @@
|
|
|
333
335
|
|
|
334
336
|
.matchTag[data-kind='full-text'] {
|
|
335
337
|
color: var(--dsw-alias-brand-primary, #4d6bfe);
|
|
336
|
-
border-color: var(--dsw-alias-brand-
|
|
338
|
+
border-color: color-mix(in srgb, var(--dsw-alias-brand-primary, #4d6bfe) 32%, transparent);
|
|
337
339
|
background: transparent;
|
|
338
340
|
}
|
|
339
341
|
|
|
@@ -368,7 +370,7 @@
|
|
|
368
370
|
|
|
369
371
|
.snippetMark {
|
|
370
372
|
color: var(--dsw-alias-brand-primary, #4d6bfe);
|
|
371
|
-
background: var(--dsw-alias-brand-
|
|
373
|
+
background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4d6bfe) 12%, transparent);
|
|
372
374
|
border-radius: 2px;
|
|
373
375
|
font-weight: 600;
|
|
374
376
|
}
|