@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.
@@ -14,15 +14,18 @@
14
14
  * blanking data the user already has.
15
15
  */
16
16
 
17
+ import { useState } from 'react'
17
18
  import type { ReactElement } from 'react'
18
19
  import {
19
20
  buildProjectViewModel,
21
+ LANE_SESSION_LIMIT,
20
22
  PROJECT_VIEW_STRINGS,
21
23
  type AgentLaneVM,
22
24
  type DerivedProjectGroupVM,
23
25
  type DerivedProjectSessionVM,
24
26
  type ProjectGroupVM,
25
27
  } from './project-view-logic.ts'
28
+ import { formatTemplate, sliceCardsForDisplay } from './logic.ts'
26
29
  import styles from './project-view.module.css'
27
30
 
28
31
  export interface ProjectViewProps {
@@ -74,6 +77,11 @@ function AgentLane(props: {
74
77
  onSelect: (sessionId: string) => void
75
78
  }): ReactElement {
76
79
  const { lane, onSelect } = props
80
+ // UX-20: lanes fold past the row limit — ephemeral view state, same
81
+ // pattern as the board's group truncation. Rows are status-sorted, so
82
+ // the fold never hides a leading working/waiting run (slice guard).
83
+ const [expanded, setExpanded] = useState(false)
84
+ const { shown, hiddenCount } = sliceCardsForDisplay(lane.sessions, LANE_SESSION_LIMIT, expanded)
77
85
  return (
78
86
  <div className={styles['lane']}>
79
87
  <div className={styles['laneHead']}>
@@ -83,7 +91,7 @@ function AgentLane(props: {
83
91
  {lane.agent}
84
92
  </div>
85
93
  <div className={styles['laneSessions']}>
86
- {lane.sessions.map((session) => (
94
+ {shown.map((session) => (
87
95
  <SessionRow
88
96
  key={`${session.agent}:${session.sessionId}`}
89
97
  session={session}
@@ -91,6 +99,24 @@ function AgentLane(props: {
91
99
  />
92
100
  ))}
93
101
  </div>
102
+ {hiddenCount > 0 && (
103
+ <button
104
+ type="button"
105
+ className={styles['showMore']}
106
+ onClick={() => { setExpanded(true) }}
107
+ >
108
+ {formatTemplate(PROJECT_VIEW_STRINGS.showAllSessions, { n: lane.sessions.length })}
109
+ </button>
110
+ )}
111
+ {expanded && lane.sessions.length > LANE_SESSION_LIMIT && (
112
+ <button
113
+ type="button"
114
+ className={styles['showMore']}
115
+ onClick={() => { setExpanded(false) }}
116
+ >
117
+ {formatTemplate(PROJECT_VIEW_STRINGS.showLessSessions, { n: LANE_SESSION_LIMIT })}
118
+ </button>
119
+ )}
94
120
  </div>
95
121
  )
96
122
  }
@@ -22,10 +22,13 @@ export const BOARD_STRINGS = {
22
22
  dead: '已结束',
23
23
  unknown: '未知',
24
24
  },
25
- /** Badge attention markers; gap outranks stale (per-session > global). */
25
+ /**
26
+ * Badge attention markers. Only the per-session gap marker renders at
27
+ * card level; the global stream-health notice lives in the top banner
28
+ * alone (UX-18: repeating it on every card was pure noise).
29
+ */
26
30
  attention: {
27
31
  gap: '事件缺口',
28
- stale: '可能滞后',
29
32
  },
30
33
  /** Daemon supervisor state labels for the top-bar badge. */
31
34
  daemon: {
@@ -58,7 +61,7 @@ export const BOARD_STRINGS = {
58
61
  daemonDeferHint:
59
62
  '检测到 LaunchAgent 托管,插件不会自行启动 daemon;服务拉起后看板会自动出数。',
60
63
  filteredTitle: '当前过滤条件下没有会话',
61
- filteredHint: '试试放宽时间窗,或打开「显示已结束」。',
64
+ filteredHint: '试试放宽时间窗、取消状态过滤,或打开「显示已结束」。',
62
65
  noSessionsTitle: '暂无被观测的会话',
63
66
  noSessionsHint:
64
67
  '本机 agent(claude / codex / cursor / dsh …)开始工作后会自动出现在这里。',
@@ -67,9 +70,25 @@ export const BOARD_STRINGS = {
67
70
  topbar: {
68
71
  title: 'Sidecar 多 agent 看板',
69
72
  refresh: '刷新',
73
+ refreshing: '刷新中…',
70
74
  refreshTitle: '手动拉取最新快照',
75
+ refreshFailed: '手动刷新失败,看板仍显示原有快照',
76
+ dismiss: '知道了',
71
77
  showDead: '显示已结束',
72
78
  timeWindow: '时间窗',
79
+ /** Count badges (UX-01); working/waiting are clickable status filters. */
80
+ countWorking: '{n} 工作中',
81
+ countWaiting: '{n} 等待中',
82
+ countTotal: '共 {n} 个会话',
83
+ filterByStatusTitle: '只看「{label}」的会话',
84
+ clearStatusFilterTitle: '取消状态过滤,显示全部会话',
85
+ },
86
+ /** Group collapse / truncation controls (UX-02). */
87
+ group: {
88
+ collapseTitle: '收起该分组',
89
+ expandTitle: '展开该分组',
90
+ showAll: '展开全部 {n} 个会话',
91
+ showLess: '只看前 {n} 个',
73
92
  },
74
93
  /** Session card texts. */
75
94
  card: {
@@ -80,13 +99,18 @@ export const BOARD_STRINGS = {
80
99
  observedValue: '观察值: {status}',
81
100
  lastReconcile: '最近对账: {time}',
82
101
  neverReconciled: '尚未对账',
102
+ copyId: '点击复制完整会话 id',
103
+ copied: '已复制',
83
104
  },
84
- /** Relative time templates. */
105
+ /**
106
+ * Relative time templates. Ages ≥ 24h render as an absolute short date
107
+ * (`MM-DD HH:mm`, see formatRelativeTime) instead of a `{n} 天前` bucket
108
+ * (UX-13: whole columns of "3 天前" carried zero information).
109
+ */
85
110
  time: {
86
111
  justNow: '刚刚',
87
112
  minutesAgo: '{n} 分钟前',
88
113
  hoursAgo: '{n} 小时前',
89
- daysAgo: '{n} 天前',
90
114
  },
91
115
  /** Time-window option templates. */
92
116
  timeWindow: {
@@ -169,7 +169,11 @@ function defaultStorage(): StorageLike | null {
169
169
  }
170
170
  }
171
171
 
172
- /** Parse + validate persisted filters; anything malformed reads as absent. */
172
+ /**
173
+ * Parse + validate persisted filters; anything malformed reads as absent.
174
+ * The optional statusFilter (UX-01) survives only as one of its two legal
175
+ * values — an unrecognized token is dropped, not the whole record.
176
+ */
173
177
  export function readStoredFilters(storage: StorageLike | null): BoardFilterState | null {
174
178
  if (storage === null) return null
175
179
  try {
@@ -177,7 +181,11 @@ export function readStoredFilters(storage: StorageLike | null): BoardFilterState
177
181
  if (raw === null) return null
178
182
  const parsed: unknown = JSON.parse(raw)
179
183
  if (typeof parsed !== 'object' || parsed === null) return null
180
- const candidate = parsed as { timeWindowHours?: unknown; showDead?: unknown }
184
+ const candidate = parsed as {
185
+ timeWindowHours?: unknown
186
+ showDead?: unknown
187
+ statusFilter?: unknown
188
+ }
181
189
  if (
182
190
  typeof candidate.timeWindowHours !== 'number'
183
191
  || !Number.isFinite(candidate.timeWindowHours)
@@ -186,7 +194,14 @@ export function readStoredFilters(storage: StorageLike | null): BoardFilterState
186
194
  ) {
187
195
  return null
188
196
  }
189
- return { timeWindowHours: candidate.timeWindowHours, showDead: candidate.showDead }
197
+ const filters: BoardFilterState = {
198
+ timeWindowHours: candidate.timeWindowHours,
199
+ showDead: candidate.showDead,
200
+ }
201
+ if (candidate.statusFilter === 'working' || candidate.statusFilter === 'waiting') {
202
+ filters.statusFilter = candidate.statusFilter
203
+ }
204
+ return filters
190
205
  } catch {
191
206
  return null
192
207
  }
@@ -326,15 +341,20 @@ export class SidecarController {
326
341
  this.notify()
327
342
  }
328
343
 
329
- /** Manual refresh (board's refresh button): one out-of-band snapshot pull. */
330
- async refresh(): Promise<void> {
344
+ /**
345
+ * Manual refresh (board's refresh button): one out-of-band snapshot
346
+ * pull. Resolves true when the snapshot applied, false on failure so
347
+ * the button can surface honest feedback (UX-07) — never rejects; the
348
+ * stream (and its status surface) remains the health authority.
349
+ */
350
+ async refresh(): Promise<boolean> {
331
351
  try {
332
352
  const snapshot = await this.fetchFn({})
333
353
  this.applySnapshot(snapshot)
354
+ return true
334
355
  } catch (err) {
335
- // The stream (and its status surface) remains the health authority;
336
- // a failed manual pull only logs.
337
356
  console.error('agent-sidecar: manual refresh failed', err)
357
+ return false
338
358
  }
339
359
  }
340
360
 
@@ -5,25 +5,37 @@
5
5
  * imports. The integration layer (S7) owns transport and accumulation —
6
6
  * it feeds the {@link TimelineVM} built via logic.ts (`applyTimelinePage`
7
7
  * for history pages, `applyListenPage` for listen-mode refetches) and
8
- * handles `onLoadMore` / `onToggleListen`.
8
+ * handles `onLoadMore` / `onToggleListen` / `onRefresh`.
9
+ *
10
+ * Row pipeline (all pure, logic.ts): buildTimelineRows (gaps on the FULL
11
+ * entry list) → filterTimelineRows (UX-03 kind filter, conversation-first
12
+ * by default with an honest hidden count) → aggregateChunkRows (UX-03
13
+ * adjacent empty streaming chunks collapse into one expandable run) →
14
+ * limitTimelineRows (render cap with a 全部显示 escape hatch).
9
15
  *
10
16
  * Long-list posture (task report): no full virtualization — history only
11
17
  * grows page-by-page on explicit 加载更多, and rendering is additionally
12
- * capped at {@link DEFAULT_MAX_RENDER_ROWS} newest rows behind a collapse
13
- * notice with a 全部显示 escape hatch. View-local concerns (expanded
14
- * bodies, the lift-cap flag, auto-scroll) are component state; everything
18
+ * capped at {@link DEFAULT_MAX_RENDER_ROWS} newest rows. View-local
19
+ * concerns (expanded bodies/runs, filter mode, the lift-cap flag, the
20
+ * UX-04 initial landing, copy feedback) are component state; everything
15
21
  * else comes through props.
16
22
  */
17
23
 
18
- import { useEffect, useRef, useState, type ReactElement } from 'react'
24
+ import { Fragment, useEffect, useRef, useState, type ReactElement } from 'react'
19
25
  import {
26
+ aggregateChunkRows,
20
27
  buildTimelineRows,
21
28
  deriveDetailBodyState,
22
29
  deriveDetailStatus,
23
30
  deriveSourceBadges,
24
31
  agentGlyph,
32
+ filterTimelineRows,
33
+ formatTemplate,
25
34
  limitTimelineRows,
35
+ shouldStickToLatest,
26
36
  DEFAULT_MAX_RENDER_ROWS,
37
+ type TimelineEventRowVM,
38
+ type TimelineFilterMode,
27
39
  type TimelineRowVM,
28
40
  type TimelineVM,
29
41
  } from './logic.ts'
@@ -51,8 +63,12 @@ export interface SessionDetailProps {
51
63
  hasMore: boolean
52
64
  /** Listen mode (SSE-triggered newest-page refetch) currently on. */
53
65
  listening: boolean
66
+ /** True while a manual newest-window refresh is in flight (UX-07). */
67
+ refreshing?: boolean
54
68
  onLoadMore: () => void
55
69
  onToggleListen: () => void
70
+ /** Manual newest-window refetch; the button renders only when given. */
71
+ onRefresh?: () => void
56
72
  onClose?: () => void
57
73
  /** Clock injection for deterministic rendering; defaults to Date.now(). */
58
74
  nowMs?: number
@@ -61,7 +77,7 @@ export interface SessionDetailProps {
61
77
  }
62
78
 
63
79
  function EventRow(props: {
64
- row: Extract<TimelineRowVM, { type: 'event' }>
80
+ row: TimelineEventRowVM
65
81
  expanded: boolean
66
82
  onToggleExpand: (key: string) => void
67
83
  }): ReactElement {
@@ -86,7 +102,7 @@ function EventRow(props: {
86
102
  )}
87
103
  {row.isNew && <span className={styles['eventNew']}>{DETAIL_STRINGS.timeline.newBadge}</span>}
88
104
  <span className={styles['eventSpacer']} />
89
- <span className={styles['eventTime']}>{row.relativeTime}</span>
105
+ <span className={styles['eventTime']}>{row.timeLabel}</span>
90
106
  </div>
91
107
  {entry.summary !== '' && <div className={styles['eventSummary']}>{entry.summary}</div>}
92
108
  {entry.expandable && (
@@ -105,12 +121,58 @@ function EventRow(props: {
105
121
  )
106
122
  }
107
123
 
124
+ /** Collapsed run of adjacent streaming chunks (UX-03); expandable lossless. */
125
+ function ChunkRunRow(props: {
126
+ row: Extract<TimelineRowVM, { type: 'chunks' }>
127
+ expanded: boolean
128
+ onToggleRun: (key: string) => void
129
+ expandedKeys: ReadonlySet<string>
130
+ onToggleExpand: (key: string) => void
131
+ }): ReactElement {
132
+ const { row } = props
133
+ return (
134
+ <Fragment>
135
+ <li
136
+ className={styles['chunkRun']}
137
+ data-new={row.isNew || undefined}
138
+ title={row.hoverTitle}
139
+ data-testid="agent-sidecar-detail-chunks"
140
+ >
141
+ <span className={styles['chunkRunLabel']}>{row.label}</span>
142
+ <button
143
+ type="button"
144
+ className={styles['expandButton']}
145
+ onClick={() => props.onToggleRun(row.key)}
146
+ >
147
+ {props.expanded ? DETAIL_STRINGS.timeline.collapse : DETAIL_STRINGS.timeline.expand}
148
+ </button>
149
+ <span className={styles['eventSpacer']} />
150
+ <span className={styles['eventTime']}>{row.timeLabel}</span>
151
+ </li>
152
+ {props.expanded &&
153
+ row.members.map((member) => (
154
+ <EventRow
155
+ key={member.key}
156
+ row={member}
157
+ expanded={props.expandedKeys.has(member.key)}
158
+ onToggleExpand={props.onToggleExpand}
159
+ />
160
+ ))}
161
+ </Fragment>
162
+ )
163
+ }
164
+
108
165
  /** The session-detail view. Pure render of the logic.ts pipelines over props. */
109
166
  export function SessionDetail(props: SessionDetailProps): ReactElement {
110
167
  const nowMs = props.nowMs ?? Date.now()
111
168
  const [expandedKeys, setExpandedKeys] = useState<ReadonlySet<string>>(new Set())
169
+ const [expandedRuns, setExpandedRuns] = useState<ReadonlySet<string>>(new Set())
170
+ const [filterMode, setFilterMode] = useState<TimelineFilterMode>('conversation')
112
171
  const [renderAll, setRenderAll] = useState(false)
172
+ const [copied, setCopied] = useState(false)
113
173
  const listRef = useRef<HTMLOListElement | null>(null)
174
+ const positionedRef = useRef(false)
175
+ const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
114
176
 
115
177
  const status = deriveDetailStatus(props.header.status)
116
178
  const sourceBadges = deriveSourceBadges(props.timeline.sources)
@@ -121,19 +183,39 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
121
183
  })
122
184
 
123
185
  const allRows = buildTimelineRows(props.timeline, nowMs)
186
+ const filtered = filterTimelineRows(allRows, filterMode)
187
+ const aggregated = aggregateChunkRows(filtered.rows)
124
188
  const limited = renderAll
125
- ? { rows: allRows, hiddenCount: 0, notice: null }
126
- : limitTimelineRows(allRows, props.maxRenderRows ?? DEFAULT_MAX_RENDER_ROWS)
189
+ ? { rows: aggregated, hiddenCount: 0, notice: null }
190
+ : limitTimelineRows(aggregated, props.maxRenderRows ?? DEFAULT_MAX_RENDER_ROWS)
127
191
 
128
192
  const entryCount = props.timeline.entries.length
129
193
  const listening = props.listening
130
194
  useEffect(() => {
131
- // Listen mode appends at the tail: keep the newest events in view.
132
- if (!listening) return
195
+ // UX-04 landing + listen-mode tail pinning (shouldStickToLatest):
196
+ // first non-empty render lands on the newest events; listen appends
197
+ // keep them in view; paging back never yanks the viewport.
133
198
  const list = listRef.current
134
- if (list !== null) list.scrollTop = list.scrollHeight
199
+ if (list === null) return
200
+ if (
201
+ shouldStickToLatest({
202
+ entryCount,
203
+ positioned: positionedRef.current,
204
+ listening,
205
+ })
206
+ ) {
207
+ list.scrollTop = list.scrollHeight
208
+ positionedRef.current = true
209
+ }
135
210
  }, [listening, entryCount])
136
211
 
212
+ useEffect(
213
+ () => () => {
214
+ if (copyTimerRef.current !== null) clearTimeout(copyTimerRef.current)
215
+ },
216
+ [],
217
+ )
218
+
137
219
  const toggleExpand = (key: string): void => {
138
220
  setExpandedKeys((prev) => {
139
221
  const next = new Set(prev)
@@ -143,6 +225,32 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
143
225
  })
144
226
  }
145
227
 
228
+ const toggleRun = (key: string): void => {
229
+ setExpandedRuns((prev) => {
230
+ const next = new Set(prev)
231
+ if (next.has(key)) next.delete(key)
232
+ else next.add(key)
233
+ return next
234
+ })
235
+ }
236
+
237
+ const copySessionId = (): void => {
238
+ const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard
239
+ if (clipboard === undefined) return
240
+ clipboard.writeText(props.sessionId).then(
241
+ () => {
242
+ setCopied(true)
243
+ if (copyTimerRef.current !== null) clearTimeout(copyTimerRef.current)
244
+ copyTimerRef.current = setTimeout(() => {
245
+ setCopied(false)
246
+ }, 2000)
247
+ },
248
+ () => {
249
+ // Clipboard permission denied: silently keep the plain id display.
250
+ },
251
+ )
252
+ }
253
+
146
254
  return (
147
255
  <div className={styles['root']} data-testid="agent-sidecar-detail">
148
256
  <header className={styles['header']}>
@@ -163,6 +271,20 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
163
271
  {status.label}
164
272
  </span>
165
273
  <span className={styles['spacer']} />
274
+ {props.onRefresh !== undefined && (
275
+ <button
276
+ type="button"
277
+ className={styles['refreshButton']}
278
+ disabled={props.refreshing === true}
279
+ title={DETAIL_STRINGS.header.refreshHint}
280
+ onClick={props.onRefresh}
281
+ data-testid="agent-sidecar-detail-refresh"
282
+ >
283
+ {props.refreshing === true
284
+ ? DETAIL_STRINGS.header.refreshing
285
+ : DETAIL_STRINGS.header.refresh}
286
+ </button>
287
+ )}
166
288
  <button
167
289
  type="button"
168
290
  className={styles['listenButton']}
@@ -183,9 +305,20 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
183
305
  ? DETAIL_STRINGS.header.unknownProject
184
306
  : props.header.project}
185
307
  </span>
186
- <span className={styles['sessionId']} title={props.sessionId}>
308
+ <button
309
+ type="button"
310
+ className={styles['sessionId']}
311
+ title={`${props.sessionId} · ${DETAIL_STRINGS.header.copyIdTitle}`}
312
+ onClick={copySessionId}
313
+ data-testid="agent-sidecar-detail-copy-id"
314
+ >
187
315
  {props.sessionId}
188
- </span>
316
+ </button>
317
+ {copied && (
318
+ <span className={styles['copiedBubble']} role="status">
319
+ {DETAIL_STRINGS.header.copied}
320
+ </span>
321
+ )}
189
322
  </div>
190
323
  <div className={styles['metaRow']}>
191
324
  <span className={styles['disclaimer']}>{DETAIL_STRINGS.header.observedDisclaimer}</span>
@@ -214,6 +347,27 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
214
347
  </div>
215
348
  ) : (
216
349
  <>
350
+ <div className={styles['filterRow']} data-testid="agent-sidecar-detail-filter">
351
+ {(['conversation', 'all'] as const).map((mode) => (
352
+ <button
353
+ key={mode}
354
+ type="button"
355
+ className={styles['filterChip']}
356
+ data-active={filterMode === mode || undefined}
357
+ aria-pressed={filterMode === mode}
358
+ onClick={() => {
359
+ setFilterMode(mode)
360
+ }}
361
+ >
362
+ {DETAIL_STRINGS.filter[mode]}
363
+ </button>
364
+ ))}
365
+ {filtered.hiddenCount > 0 && (
366
+ <span className={styles['filterHiddenNote']}>
367
+ {formatTemplate(DETAIL_STRINGS.filter.hiddenNotice, { n: filtered.hiddenCount })}
368
+ </span>
369
+ )}
370
+ </div>
217
371
  <div className={styles['pager']}>
218
372
  {props.hasMore ? (
219
373
  <button
@@ -253,6 +407,15 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
253
407
  >
254
408
  {row.label}
255
409
  </li>
410
+ ) : row.type === 'chunks' ? (
411
+ <ChunkRunRow
412
+ key={row.key}
413
+ row={row}
414
+ expanded={expandedRuns.has(row.key)}
415
+ onToggleRun={toggleRun}
416
+ expandedKeys={expandedKeys}
417
+ onToggleExpand={toggleExpand}
418
+ />
256
419
  ) : (
257
420
  <EventRow
258
421
  key={row.key}
@@ -128,7 +128,30 @@
128
128
  .listenButton[data-active] {
129
129
  color: var(--dsw-alias-brand-primary, #4d6bfe);
130
130
  border-color: var(--dsw-alias-brand-primary, #4d6bfe);
131
- background: var(--dsw-alias-brand-tertiary, rgba(77, 107, 254, 0.08));
131
+ /* brand tint from the real token (brand-secondary/tertiary do not exist
132
+ * in the dsh token table — UX-15). */
133
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4d6bfe) 8%, transparent);
134
+ }
135
+
136
+ .refreshButton {
137
+ font-size: 12px;
138
+ font-family: inherit;
139
+ color: var(--dsw-alias-label-secondary, #57606a);
140
+ background: transparent;
141
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.08));
142
+ border-radius: 999px;
143
+ padding: 2px 12px;
144
+ cursor: pointer;
145
+ }
146
+
147
+ .refreshButton:hover:enabled {
148
+ color: var(--dsw-alias-label-primary, #1f2328);
149
+ border-color: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
150
+ }
151
+
152
+ .refreshButton:disabled {
153
+ cursor: default;
154
+ opacity: 0.6;
132
155
  }
133
156
 
134
157
  .title {
@@ -155,15 +178,35 @@
155
178
  white-space: nowrap;
156
179
  }
157
180
 
181
+ /* Click-to-copy session id (UX-17): plain-text look, button semantics. */
158
182
  .sessionId {
159
183
  font-size: 11px;
160
184
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
161
185
  color: var(--dsw-alias-label-tertiary, #6e7781);
186
+ background: transparent;
187
+ border: none;
188
+ padding: 0;
162
189
  overflow: hidden;
163
190
  text-overflow: ellipsis;
164
191
  white-space: nowrap;
165
192
  flex: none;
166
193
  max-width: 40%;
194
+ cursor: pointer;
195
+ }
196
+
197
+ .sessionId:hover {
198
+ color: var(--dsw-alias-label-primary, #1f2328);
199
+ text-decoration: underline;
200
+ }
201
+
202
+ .copiedBubble {
203
+ flex: none;
204
+ font-size: 10px;
205
+ line-height: 16px;
206
+ padding: 0 6px;
207
+ border-radius: 999px;
208
+ color: var(--dsw-alias-state-success-primary, #1a7f37);
209
+ border: 1px solid var(--dsw-alias-state-success-secondary, rgba(26, 127, 55, 0.24));
167
210
  }
168
211
 
169
212
  .metaRow {
@@ -247,6 +290,43 @@
247
290
  color: var(--dsw-alias-label-secondary, #57606a);
248
291
  }
249
292
 
293
+ /* --------------------------------------- kind filter chips (UX-03) */
294
+
295
+ .filterRow {
296
+ display: flex;
297
+ align-items: center;
298
+ gap: 6px;
299
+ flex-wrap: wrap;
300
+ }
301
+
302
+ .filterChip {
303
+ font-size: 11px;
304
+ font-family: inherit;
305
+ line-height: 18px;
306
+ padding: 0 10px;
307
+ border-radius: 999px;
308
+ color: var(--dsw-alias-label-secondary, #57606a);
309
+ background: transparent;
310
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.08));
311
+ cursor: pointer;
312
+ }
313
+
314
+ .filterChip:hover {
315
+ color: var(--dsw-alias-label-primary, #1f2328);
316
+ border-color: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
317
+ }
318
+
319
+ .filterChip[data-active] {
320
+ color: var(--dsw-alias-brand-primary, #4d6bfe);
321
+ border-color: var(--dsw-alias-brand-primary, #4d6bfe);
322
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4d6bfe) 8%, transparent);
323
+ }
324
+
325
+ .filterHiddenNote {
326
+ font-size: 11px;
327
+ color: var(--dsw-alias-label-tertiary, #6e7781);
328
+ }
329
+
250
330
  /* --------------------------------------------------------------- pager */
251
331
 
252
332
  .pager {
@@ -325,7 +405,30 @@
325
405
 
326
406
  .event[data-new] {
327
407
  border-color: var(--dsw-alias-brand-primary, #4d6bfe);
328
- background: var(--dsw-alias-brand-tertiary, rgba(77, 107, 254, 0.06));
408
+ /* brand tint from the real token (UX-15). */
409
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4d6bfe) 6%, transparent);
410
+ }
411
+
412
+ /* Aggregated streaming-chunk run (UX-03): quiet one-line stand-in. */
413
+ .chunkRun {
414
+ display: flex;
415
+ align-items: center;
416
+ gap: 8px;
417
+ font-size: 11px;
418
+ line-height: 16px;
419
+ padding: 4px 10px;
420
+ border-radius: 6px;
421
+ color: var(--dsw-alias-label-tertiary, #6e7781);
422
+ background: var(--dsw-alias-bg-layer-1, rgba(0, 0, 0, 0.02));
423
+ border: 1px dashed var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
424
+ }
425
+
426
+ .chunkRun[data-new] {
427
+ border-color: var(--dsw-alias-brand-primary, #4d6bfe);
428
+ }
429
+
430
+ .chunkRunLabel {
431
+ flex: none;
329
432
  }
330
433
 
331
434
  .eventHead {