better-codex 0.1.4 → 0.2.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.
@@ -1,7 +1,7 @@
1
1
  import { useRef, useEffect, useState, useCallback, useMemo } from 'react'
2
2
  import { useVirtualizer } from '@tanstack/react-virtual'
3
- import type { Message, ApprovalRequest, ThreadStatus, QueuedMessage } from '../../types'
4
- import { Avatar, Button, Icons, CollapsibleContent, ThinkingIndicator } from '../ui'
3
+ import type { Message, ApprovalRequest, ThreadStatus, QueuedMessage, CommandAction, FileChangeMeta } from '../../types'
4
+ import { Avatar, Button, Icons, CollapsibleContent, ThinkingIndicator, ShimmerText } from '../ui'
5
5
  import { Markdown } from '../ui'
6
6
 
7
7
  interface VirtualizedMessageListProps {
@@ -31,16 +31,350 @@ interface AssistantAction {
31
31
  summary?: string
32
32
  }
33
33
 
34
+ type ActionRowItem = {
35
+ label: string
36
+ detail: string
37
+ }
38
+
39
+ type FileChangeStat = {
40
+ path: string
41
+ movePath?: string | null
42
+ added: number
43
+ removed: number
44
+ kind: FileChangeMeta['kind']
45
+ }
46
+
47
+ const isInProgressStatus = (value?: string) => {
48
+ if (!value) {
49
+ return false
50
+ }
51
+ const normalized = value.replace(/[_\s]/g, '').toLowerCase()
52
+ return normalized === 'inprogress'
53
+ }
54
+
55
+ const uniqueStrings = (values: string[]) => {
56
+ const set = new Set(values.map((value) => value.trim()).filter(Boolean))
57
+ return Array.from(set)
58
+ }
59
+
60
+ const compactList = (values: string[], maxItems = 3) => {
61
+ const unique = uniqueStrings(values)
62
+ if (unique.length <= maxItems) {
63
+ return unique.join(', ')
64
+ }
65
+ const head = unique.slice(0, maxItems)
66
+ const remaining = unique.length - maxItems
67
+ return `${head.join(', ')} +${remaining}`
68
+ }
69
+
70
+ const isStatusText = (value: string) => {
71
+ const normalized = value.replace(/[_\s]/g, '').toLowerCase()
72
+ return (
73
+ normalized === 'completed' ||
74
+ normalized === 'inprogress' ||
75
+ normalized === 'failed' ||
76
+ normalized === 'declined' ||
77
+ normalized === 'canceled' ||
78
+ normalized === 'cancelled'
79
+ )
80
+ }
81
+
82
+ const commandActionDetail = (action: CommandAction) => {
83
+ switch (action.type) {
84
+ case 'read':
85
+ return action.name || action.path || action.command
86
+ case 'listFiles':
87
+ return action.path || action.command
88
+ case 'search': {
89
+ const query = action.query || action.command
90
+ if (action.path) {
91
+ return `${query} in ${action.path}`
92
+ }
93
+ return query
94
+ }
95
+ default:
96
+ return action.command
97
+ }
98
+ }
99
+
100
+ const buildCommandActionRows = (messages: Message[]): ActionRowItem[] => {
101
+ const rows: ActionRowItem[] = []
102
+ let pendingReads: string[] = []
103
+
104
+ const flushReads = () => {
105
+ if (!pendingReads.length) {
106
+ return
107
+ }
108
+ const detail = compactList(pendingReads)
109
+ if (!isStatusText(detail)) {
110
+ rows.push({
111
+ label: 'Read',
112
+ detail,
113
+ })
114
+ }
115
+ pendingReads = []
116
+ }
117
+
118
+ for (const message of messages) {
119
+ const actions = message.meta?.commandActions ?? []
120
+ if (actions.length) {
121
+ const allRead = actions.every((action) => action.type === 'read')
122
+ if (allRead) {
123
+ pendingReads.push(...actions.map(commandActionDetail))
124
+ continue
125
+ }
126
+ flushReads()
127
+ actions.forEach((action) => {
128
+ const label =
129
+ action.type === 'read'
130
+ ? 'Read'
131
+ : action.type === 'listFiles'
132
+ ? 'List'
133
+ : action.type === 'search'
134
+ ? 'Search'
135
+ : 'Run'
136
+ const detail = commandActionDetail(action)
137
+ if (detail && !isStatusText(detail)) {
138
+ rows.push({ label, detail })
139
+ }
140
+ })
141
+ continue
142
+ }
143
+
144
+ flushReads()
145
+ const firstLine = message.content.split('\n')[0]?.trim()
146
+ if (firstLine && !isStatusText(firstLine)) {
147
+ rows.push({ label: 'Result', detail: firstLine })
148
+ }
149
+ }
150
+
151
+ flushReads()
152
+ return rows
153
+ }
154
+
155
+ const countDiffLines = (diff: string, kind: FileChangeMeta['kind']) => {
156
+ const lines = diff.split(/\r?\n/u)
157
+ if (kind === 'add') {
158
+ return { added: lines.length, removed: 0 }
159
+ }
160
+ if (kind === 'delete') {
161
+ return { added: 0, removed: lines.length }
162
+ }
163
+
164
+ let added = 0
165
+ let removed = 0
166
+ for (const line of lines) {
167
+ if (
168
+ line.startsWith('+++') ||
169
+ line.startsWith('---') ||
170
+ line.startsWith('@@') ||
171
+ line.startsWith('diff --git') ||
172
+ line.startsWith('index ') ||
173
+ line.startsWith('Moved to:')
174
+ ) {
175
+ continue
176
+ }
177
+ if (line.startsWith('+')) {
178
+ added += 1
179
+ } else if (line.startsWith('-')) {
180
+ removed += 1
181
+ }
182
+ }
183
+ return { added, removed }
184
+ }
185
+
186
+ const parseUnifiedDiffStats = (diff: string): FileChangeStat[] => {
187
+ const stats = new Map<string, FileChangeStat>()
188
+ let currentKey: string | null = null
189
+
190
+ const ensureEntry = (path: string) => {
191
+ const existing = stats.get(path)
192
+ if (existing) {
193
+ currentKey = path
194
+ return existing
195
+ }
196
+ const created: FileChangeStat = { path, added: 0, removed: 0, kind: 'update' }
197
+ stats.set(path, created)
198
+ currentKey = path
199
+ return created
200
+ }
201
+
202
+ for (const line of diff.split(/\r?\n/u)) {
203
+ const header = line.match(/^diff --git a\/(.+?) b\/(.+)$/u)
204
+ if (header) {
205
+ ensureEntry(header[2])
206
+ continue
207
+ }
208
+ const plusHeader = line.match(/^\+\+\+\s+(?:b\/)?(.+)$/u)
209
+ if (plusHeader) {
210
+ ensureEntry(plusHeader[1])
211
+ continue
212
+ }
213
+ if (!currentKey) {
214
+ continue
215
+ }
216
+ const current = stats.get(currentKey)
217
+ if (!current) {
218
+ continue
219
+ }
220
+ if (
221
+ line.startsWith('+++') ||
222
+ line.startsWith('---') ||
223
+ line.startsWith('@@') ||
224
+ line.startsWith('index ')
225
+ ) {
226
+ continue
227
+ }
228
+ if (line.startsWith('+')) {
229
+ current.added += 1
230
+ } else if (line.startsWith('-')) {
231
+ current.removed += 1
232
+ }
233
+ }
234
+
235
+ return Array.from(stats.values())
236
+ }
237
+
238
+ const parseFileChangesFromContent = (content: string): FileChangeStat[] => {
239
+ const stats: FileChangeStat[] = []
240
+ const lines = content.split(/\r?\n/u)
241
+ for (const line of lines) {
242
+ const match = line.match(/^\s*([a-zA-Z]+)\s*:\s*(.+)$/u)
243
+ if (!match) {
244
+ continue
245
+ }
246
+ const kindRaw = match[1].toLowerCase()
247
+ const detail = match[2].trim()
248
+ const [path, movePath] = detail.split(/\s*->\s*/u)
249
+ const kind =
250
+ kindRaw === 'add' || kindRaw === 'added'
251
+ ? 'add'
252
+ : kindRaw === 'delete' || kindRaw === 'deleted'
253
+ ? 'delete'
254
+ : 'update'
255
+ stats.push({
256
+ path: path.trim(),
257
+ movePath: movePath?.trim() ?? null,
258
+ added: 0,
259
+ removed: 0,
260
+ kind,
261
+ })
262
+ }
263
+ return stats
264
+ }
265
+
266
+ const shortenPath = (value: string) => {
267
+ const normalized = value.replace(/\\/g, '/')
268
+ const parts = normalized.split('/').filter(Boolean)
269
+ if (parts.length <= 2) {
270
+ return value
271
+ }
272
+ return `${parts[parts.length - 2]}/${parts[parts.length - 1]}`
273
+ }
274
+
275
+ const buildFileChangeStats = (messages: Message[]): FileChangeStat[] => {
276
+ const stats: FileChangeStat[] = []
277
+ let diffFallback: string | null = null
278
+ let contentFallback: string | null = null
279
+
280
+ for (const message of messages) {
281
+ const changes = message.meta?.fileChanges ?? []
282
+ for (const change of changes) {
283
+ const diff = change.diff ?? ''
284
+ const { added, removed } = countDiffLines(diff, change.kind)
285
+ stats.push({
286
+ path: change.path,
287
+ movePath: change.movePath ?? undefined,
288
+ added,
289
+ removed,
290
+ kind: change.kind,
291
+ })
292
+ }
293
+ if (!changes.length && message.meta?.diff) {
294
+ diffFallback = message.meta.diff
295
+ }
296
+ if (!changes.length && message.content && !contentFallback) {
297
+ contentFallback = message.content
298
+ }
299
+ }
300
+
301
+ if (!stats.length && diffFallback) {
302
+ return parseUnifiedDiffStats(diffFallback)
303
+ }
304
+ if (!stats.length && contentFallback) {
305
+ return parseFileChangesFromContent(contentFallback)
306
+ }
307
+ return stats
308
+ }
309
+
310
+ const summarizeFileChanges = (stats: FileChangeStat[]) => {
311
+ if (!stats.length) {
312
+ return { summary: '', rows: [] as ActionRowItem[], verb: 'Edited' }
313
+ }
314
+
315
+ const totalAdded = stats.reduce((sum, entry) => sum + entry.added, 0)
316
+ const totalRemoved = stats.reduce((sum, entry) => sum + entry.removed, 0)
317
+ const hasCounts = totalAdded > 0 || totalRemoved > 0
318
+
319
+ const formatCounts = (added: number, removed: number) =>
320
+ hasCounts ? `(+${added} -${removed})` : ''
321
+ const isSingle = stats.length === 1
322
+ const verb = isSingle
323
+ ? stats[0].kind === 'add'
324
+ ? 'Added'
325
+ : stats[0].kind === 'delete'
326
+ ? 'Deleted'
327
+ : 'Edited'
328
+ : 'Edited'
329
+
330
+ const summary = isSingle
331
+ ? [
332
+ `${shortenPath(stats[0].path)}${stats[0].movePath ? ` \u2192 ${shortenPath(stats[0].movePath)}` : ''}`.trim(),
333
+ formatCounts(stats[0].added, stats[0].removed),
334
+ ]
335
+ .filter(Boolean)
336
+ .join(' ')
337
+ : [
338
+ `${stats.length} files`.trim(),
339
+ formatCounts(totalAdded, totalRemoved),
340
+ ]
341
+ .filter(Boolean)
342
+ .join(' ')
343
+
344
+ const rows = stats.map((entry) => ({
345
+ label: entry.kind === 'add' ? 'Added' : entry.kind === 'delete' ? 'Deleted' : 'Edited',
346
+ detail: [
347
+ `${entry.path}${entry.movePath ? ` \u2192 ${entry.movePath}` : ''}`.trim(),
348
+ formatCounts(entry.added, entry.removed),
349
+ ]
350
+ .filter(Boolean)
351
+ .join(' '),
352
+ }))
353
+
354
+ return { summary, rows, verb }
355
+ }
356
+
34
357
  function getActionType(msg: Message): AssistantAction['type'] {
35
358
  if (msg.kind === 'reasoning') return 'reasoning'
36
359
  if (msg.kind === 'file') {
360
+ if (msg.meta?.fileChanges?.length || msg.meta?.diff) {
361
+ return 'edited'
362
+ }
37
363
  const title = msg.title?.toLowerCase() ?? ''
38
- if (title.includes('edit') || title.includes('wrote') || title.includes('creat')) return 'edited'
39
- return 'explored'
364
+ if (title.includes('diff')) return 'edited'
365
+ return 'edited'
40
366
  }
41
367
  if (msg.kind === 'command') return 'ran'
42
368
  if (msg.kind === 'tool') {
369
+ if (msg.meta?.commandActions?.length) {
370
+ const actions = msg.meta.commandActions
371
+ const exploratory = actions.every((action) =>
372
+ ['read', 'search', 'listFiles'].includes(action.type)
373
+ )
374
+ return exploratory ? 'explored' : 'ran'
375
+ }
43
376
  const title = msg.title?.toLowerCase() ?? ''
377
+ if (title.includes('account switch') || title.includes('new conversation')) return 'chat'
44
378
  if (title.includes('web search')) return 'searched'
45
379
  if (title.includes('read') || title.includes('view') || title.includes('list')) return 'explored'
46
380
  if (title.includes('edit') || title.includes('wrote') || title.includes('creat')) return 'edited'
@@ -110,6 +444,15 @@ function getActionLabel(type: AssistantAction['type'], messages: Message[]): { l
110
444
  }
111
445
  }
112
446
 
447
+ const extractReasoningHeadline = (content: string) => {
448
+ const match = content.match(/(?:^|\n)\s*\*\*(.+?)\*\*/u)
449
+ if (match?.[1]) {
450
+ return match[1].trim()
451
+ }
452
+ const firstLine = content.split('\n').map((line) => line.trim()).find(Boolean)
453
+ return firstLine || null
454
+ }
455
+
113
456
  function groupMessagesIntoTurns(messages: Message[]): Turn[] {
114
457
  const turns: Turn[] = []
115
458
  let currentTurn: Turn | null = null
@@ -231,30 +574,57 @@ export function VirtualizedMessageList({
231
574
  }: VirtualizedMessageListProps) {
232
575
  const parentRef = useRef<HTMLDivElement>(null)
233
576
  const [userHasScrolled, setUserHasScrolled] = useState(false)
577
+ const [listHeight, setListHeight] = useState(0)
234
578
  const lastScrollTop = useRef(0)
235
579
  const isAutoScrolling = useRef(false)
236
580
  const prevItemsLength = useRef(0)
237
-
238
581
  const lastMessage = messages[messages.length - 1]
582
+ const userInteractedRef = useRef(false)
583
+ const seenItemIds = useRef(new Set<string>())
584
+ const lastMessageSignature = useMemo(() => {
585
+ if (!lastMessage) {
586
+ return ''
587
+ }
588
+ return `${lastMessage.id}:${lastMessage.content.length}`
589
+ }, [lastMessage])
590
+
239
591
  const isWaitingForResponse = threadStatus === 'active' && lastMessage?.role === 'user'
240
592
  const isTaskRunning = threadStatus === 'active'
241
593
 
242
594
  const turns = useMemo(() => groupMessagesIntoTurns(messages), [messages])
595
+ const workingBarHeight = 64
596
+ const baseBuffer = listHeight ? Math.round(listHeight * 0.3) : 0
597
+ const extraBuffer = Math.min(360, Math.max(120, baseBuffer))
598
+ const bottomSpacerHeight = extraBuffer + (isTaskRunning ? workingBarHeight : 0)
599
+ const activeReasoningHeadline = useMemo(() => {
600
+ if (!isTaskRunning) {
601
+ return null
602
+ }
603
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
604
+ const message = messages[i]
605
+ if (message.kind !== 'reasoning' || !message.content.trim()) {
606
+ continue
607
+ }
608
+ const headline = extractReasoningHeadline(message.content)
609
+ if (headline) {
610
+ return headline
611
+ }
612
+ }
613
+ return null
614
+ }, [isTaskRunning, messages])
243
615
 
244
616
  const items: Array<
245
617
  | { type: 'turn'; data: Turn }
246
618
  | { type: 'approval'; data: ApprovalRequest }
247
- | { type: 'thinking'; data: null }
248
- | { type: 'working'; data: { startedAt: number } }
249
619
  | { type: 'worked'; data: { duration: number } }
250
620
  | { type: 'queued'; data: QueuedMessage }
621
+ | { type: 'spacer'; data: { height: number } }
251
622
  > = [
252
623
  ...turns.map(t => ({ type: 'turn' as const, data: t })),
253
624
  ...approvals.map(a => ({ type: 'approval' as const, data: a })),
254
- ...(isWaitingForResponse ? [{ type: 'thinking' as const, data: null }] : []),
255
- ...(isTaskRunning && !isWaitingForResponse && turnStartedAt ? [{ type: 'working' as const, data: { startedAt: turnStartedAt } }] : []),
256
625
  ...(!isTaskRunning && lastTurnDuration ? [{ type: 'worked' as const, data: { duration: lastTurnDuration } }] : []),
257
626
  ...queuedMessages.map(q => ({ type: 'queued' as const, data: q })),
627
+ ...(bottomSpacerHeight > 0 ? [{ type: 'spacer' as const, data: { height: bottomSpacerHeight } }] : []),
258
628
  ]
259
629
 
260
630
  const initialScrollDone = useRef(false)
@@ -265,10 +635,9 @@ export function VirtualizedMessageList({
265
635
  estimateSize: (index) => {
266
636
  const item = items[index]
267
637
  if (item.type === 'approval') return 140
268
- if (item.type === 'thinking') return 60
269
- if (item.type === 'working') return 60
270
638
  if (item.type === 'worked') return 40
271
639
  if (item.type === 'queued') return 80
640
+ if (item.type === 'spacer') return item.data.height
272
641
  const turn = item.data as Turn
273
642
  const userHeight = turn.userMessage ? 80 : 0
274
643
  const actionsHeight = turn.assistantActions.reduce((acc, action) => {
@@ -289,12 +658,19 @@ export function VirtualizedMessageList({
289
658
  const { scrollTop, scrollHeight, clientHeight } = parentRef.current
290
659
  const isAtBottom = scrollHeight - scrollTop - clientHeight < 50
291
660
 
292
- if (scrollTop < lastScrollTop.current && !isAtBottom) {
293
- setUserHasScrolled(true)
661
+ if (!userInteractedRef.current) {
662
+ if (isAtBottom) {
663
+ setUserHasScrolled(false)
664
+ }
665
+ lastScrollTop.current = scrollTop
666
+ return
294
667
  }
295
-
296
- if (isAtBottom) {
668
+
669
+ if (!isAtBottom) {
670
+ setUserHasScrolled(true)
671
+ } else {
297
672
  setUserHasScrolled(false)
673
+ userInteractedRef.current = false
298
674
  }
299
675
 
300
676
  lastScrollTop.current = scrollTop
@@ -303,30 +679,68 @@ export function VirtualizedMessageList({
303
679
  useEffect(() => {
304
680
  if (items.length > 0 && !initialScrollDone.current) {
305
681
  initialScrollDone.current = true
306
- requestAnimationFrame(() => {
307
- virtualizer.scrollToIndex(items.length - 1, { align: 'end' })
682
+ queueMicrotask(() => {
683
+ requestAnimationFrame(() => {
684
+ virtualizer.scrollToIndex(items.length - 1, { align: 'end' })
685
+ })
308
686
  })
309
687
  }
310
688
  }, [items.length, virtualizer])
311
689
 
690
+ useEffect(() => {
691
+ const element = parentRef.current
692
+ if (!element) {
693
+ return
694
+ }
695
+ const updateHeight = () => {
696
+ setListHeight(element.clientHeight)
697
+ }
698
+ updateHeight()
699
+ if (typeof ResizeObserver === 'undefined') {
700
+ return
701
+ }
702
+ const observer = new ResizeObserver(() => updateHeight())
703
+ observer.observe(element)
704
+ return () => observer.disconnect()
705
+ }, [])
706
+
312
707
  useEffect(() => {
313
708
  const hasNewItems = items.length > prevItemsLength.current
314
709
  prevItemsLength.current = items.length
315
710
 
316
711
  if (items.length > 0 && hasNewItems && !userHasScrolled && initialScrollDone.current) {
317
712
  isAutoScrolling.current = true
318
- virtualizer.scrollToIndex(items.length - 1, { align: 'end', behavior: 'smooth' })
713
+ queueMicrotask(() => {
714
+ virtualizer.scrollToIndex(items.length - 1, { align: 'end', behavior: 'smooth' })
715
+ })
319
716
  setTimeout(() => {
320
717
  isAutoScrolling.current = false
321
718
  }, 500)
322
719
  }
323
720
  }, [items.length, virtualizer, userHasScrolled])
324
721
 
722
+ useEffect(() => {
723
+ if (!isTaskRunning || userHasScrolled || !initialScrollDone.current || items.length === 0) {
724
+ return
725
+ }
726
+ isAutoScrolling.current = true
727
+ queueMicrotask(() => {
728
+ requestAnimationFrame(() => {
729
+ virtualizer.scrollToIndex(items.length - 1, { align: 'end' })
730
+ setTimeout(() => {
731
+ isAutoScrolling.current = false
732
+ }, 200)
733
+ })
734
+ })
735
+ }, [isTaskRunning, userHasScrolled, lastMessageSignature, items.length, virtualizer])
736
+
325
737
  const scrollToBottom = useCallback(() => {
326
738
  if (items.length > 0) {
327
739
  setUserHasScrolled(false)
328
740
  isAutoScrolling.current = true
329
- virtualizer.scrollToIndex(items.length - 1, { align: 'end', behavior: 'smooth' })
741
+ queueMicrotask(() => {
742
+ virtualizer.scrollToIndex(items.length - 1, { align: 'end', behavior: 'smooth' })
743
+ })
330
744
  setTimeout(() => {
331
745
  isAutoScrolling.current = false
332
746
  }, 500)
@@ -345,9 +759,18 @@ export function VirtualizedMessageList({
345
759
  <div className="flex-1 relative">
346
760
  <div
347
761
  ref={parentRef}
348
- className="h-full overflow-y-auto px-3 md:px-6 touch-scroll"
762
+ className="h-full overflow-y-auto px-3 md:px-6 touch-scroll pb-4"
349
763
  style={{ contain: 'strict' }}
350
764
  onScroll={handleScroll}
765
+ onWheel={() => {
766
+ userInteractedRef.current = true
767
+ }}
768
+ onTouchMove={() => {
769
+ userInteractedRef.current = true
770
+ }}
771
+ onMouseDown={() => {
772
+ userInteractedRef.current = true
773
+ }}
351
774
  >
352
775
  <div
353
776
  className="max-w-4xl mx-auto relative"
@@ -355,11 +778,29 @@ export function VirtualizedMessageList({
355
778
  >
356
779
  {virtualizer.getVirtualItems().map((virtualItem) => {
357
780
  const item = items[virtualItem.index]
781
+ const itemKey =
782
+ item.type === 'turn'
783
+ ? `turn:${item.data.id}`
784
+ : item.type === 'approval'
785
+ ? `approval:${item.data.id}`
786
+ : item.type === 'queued'
787
+ ? `queued:${item.data.id}`
788
+ : item.type === 'worked'
789
+ ? `worked:${item.data.duration}`
790
+ : null
791
+ const isFresh = itemKey ? !seenItemIds.current.has(itemKey) : false
792
+ if (itemKey) {
793
+ seenItemIds.current.add(itemKey)
794
+ }
795
+ const shouldAnimate = isFresh && !userHasScrolled && initialScrollDone.current && item.type !== 'spacer'
796
+ const wrapperClass = item.type === 'spacer'
797
+ ? 'absolute top-0 left-0 w-full'
798
+ : `absolute top-0 left-0 w-full py-2${shouldAnimate ? ' animate-in fade-in duration-200' : ''}`
358
799
 
359
800
  return (
360
801
  <div
361
802
  key={virtualItem.key}
362
- className="absolute top-0 left-0 w-full py-2"
803
+ className={wrapperClass}
363
804
  style={{
364
805
  transform: `translateY(${virtualItem.start}px)`,
365
806
  }}
@@ -368,14 +809,12 @@ export function VirtualizedMessageList({
368
809
  >
369
810
  {item.type === 'turn' ? (
370
811
  <TurnView turn={item.data} />
371
- ) : item.type === 'thinking' ? (
372
- <ThinkingBubble onInterrupt={isTaskRunning ? onInterrupt : undefined} />
373
- ) : item.type === 'working' ? (
374
- <WorkingBubble startedAt={item.data.startedAt} onInterrupt={isTaskRunning ? onInterrupt : undefined} />
375
812
  ) : item.type === 'worked' ? (
376
813
  <WorkedBubble duration={item.data.duration} />
377
814
  ) : item.type === 'queued' ? (
378
815
  <QueuedMessageBubble message={item.data} />
816
+ ) : item.type === 'spacer' ? (
817
+ <div style={{ height: item.data.height }} />
379
818
  ) : (
380
819
  <ApprovalCard
381
820
  approval={item.data as ApprovalRequest}
@@ -390,13 +829,21 @@ export function VirtualizedMessageList({
390
829
  </div>
391
830
  </div>
392
831
 
832
+ {isTaskRunning && (
833
+ <StickyWorkingBar
834
+ message={activeReasoningHeadline ?? (isWaitingForResponse ? 'Thinking' : 'Working')}
835
+ startedAt={turnStartedAt ?? null}
836
+ onInterrupt={onInterrupt}
837
+ />
838
+ )}
839
+
393
840
  {userHasScrolled && (
394
841
  <button
395
842
  onClick={scrollToBottom}
396
- className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 px-3 py-1.5
397
- bg-bg-elevated border border-border rounded-full text-xs text-text-muted
398
- hover:text-text-primary hover:border-text-muted transition-all shadow-lg
399
- animate-in fade-in slide-in-from-bottom-2 duration-200"
843
+ className={`absolute ${isTaskRunning ? 'bottom-20' : 'bottom-4'} left-1/2 -translate-x-1/2
844
+ flex items-center gap-1.5 px-3 py-1.5 bg-bg-elevated border border-border rounded-full
845
+ text-xs text-text-muted hover:text-text-primary hover:border-text-muted transition-all
846
+ shadow-lg animate-in fade-in slide-in-from-bottom-2 duration-200`}
400
847
  >
401
848
  <Icons.ChevronDown className="w-3 h-3" />
402
849
  <span>New messages</span>
@@ -406,48 +853,53 @@ export function VirtualizedMessageList({
406
853
  )
407
854
  }
408
855
 
409
- function ThinkingBubble({ onInterrupt }: { onInterrupt?: () => void }) {
410
- return (
411
- <div className="pl-10">
412
- <div className="flex items-center gap-2 py-2">
413
- <span className="text-text-muted">•</span>
414
- <ThinkingIndicator message="Thinking" />
415
- {onInterrupt && (
416
- <button
417
- onClick={onInterrupt}
418
- className="ml-2 px-2 py-0.5 text-xs text-text-muted hover:text-error hover:bg-error/10 rounded transition-colors"
419
- >
420
- Stop
421
- </button>
422
- )}
423
- </div>
424
- </div>
856
+ function StickyWorkingBar({
857
+ message,
858
+ startedAt,
859
+ onInterrupt,
860
+ }: {
861
+ message: string
862
+ startedAt: number | null
863
+ onInterrupt?: () => void
864
+ }) {
865
+ const [elapsed, setElapsed] = useState(() =>
866
+ startedAt ? Math.floor((Date.now() - startedAt) / 1000) : 0
425
867
  )
426
- }
427
868
 
428
- function WorkingBubble({ startedAt, onInterrupt }: { startedAt: number; onInterrupt?: () => void }) {
429
- const [elapsed, setElapsed] = useState(() => Math.floor((Date.now() - startedAt) / 1000))
430
-
431
869
  useEffect(() => {
870
+ if (!startedAt) {
871
+ return
872
+ }
432
873
  const interval = setInterval(() => {
433
874
  setElapsed(Math.floor((Date.now() - startedAt) / 1000))
434
875
  }, 1000)
435
876
  return () => clearInterval(interval)
436
877
  }, [startedAt])
437
-
878
+
879
+ const formatElapsed = (secs: number) => {
880
+ if (secs < 60) return `${secs}s`
881
+ const mins = Math.floor(secs / 60)
882
+ const remaining = secs % 60
883
+ return `${mins}m ${remaining.toString().padStart(2, '0')}s`
884
+ }
885
+
438
886
  return (
439
- <div className="pl-10">
440
- <div className="flex items-center gap-2 py-2">
441
- <span className="text-text-muted">•</span>
442
- <ThinkingIndicator message="Working" elapsed={elapsed} />
443
- {onInterrupt && (
444
- <button
445
- onClick={onInterrupt}
446
- className="ml-2 px-2 py-0.5 text-xs text-text-muted hover:text-error hover:bg-error/10 rounded transition-colors"
447
- >
448
- Stop
449
- </button>
450
- )}
887
+ <div className="absolute bottom-0 left-0 right-0 px-3 md:px-6 pb-3">
888
+ <div className="max-w-4xl mx-auto">
889
+ <div className="flex items-center gap-2 bg-bg-elevated/90 border border-border rounded-full px-4 py-2 shadow-lg backdrop-blur">
890
+ <ShimmerText text={message} className="text-xs text-text-primary font-medium" />
891
+ {startedAt !== null && (
892
+ <span className="text-[10px] text-text-muted">({formatElapsed(elapsed)})</span>
893
+ )}
894
+ {onInterrupt && (
895
+ <button
896
+ onClick={onInterrupt}
897
+ className="ml-auto px-2 py-0.5 text-[10px] text-text-muted hover:text-error hover:bg-error/10 rounded transition-colors"
898
+ >
899
+ Stop
900
+ </button>
901
+ )}
902
+ </div>
451
903
  </div>
452
904
  </div>
453
905
  )
@@ -608,8 +1060,7 @@ function ActionRow({ action }: { action: AssistantAction }) {
608
1060
 
609
1061
  if (isPlaceholder) {
610
1062
  return (
611
- <div className="flex items-center gap-2 py-1.5">
612
- <span className="text-text-muted">•</span>
1063
+ <div className="flex items-center gap-2 py-1.5 pl-2">
613
1064
  <ThinkingIndicator message="Thinking" />
614
1065
  </div>
615
1066
  )
@@ -617,35 +1068,65 @@ function ActionRow({ action }: { action: AssistantAction }) {
617
1068
 
618
1069
  return (
619
1070
  <div>
620
- <button
621
- onClick={() => setIsExpanded(!isExpanded)}
622
- className="flex items-center gap-2 py-1.5 hover:bg-bg-hover/30 rounded px-2 -mx-2 transition-colors w-full text-left"
623
- >
624
- <span className="text-text-muted/60">•</span>
625
- <Icons.ChevronDown
626
- className={`w-3 h-3 text-text-muted transition-transform duration-200
627
- ${isExpanded ? '' : '-rotate-90'}`}
628
- />
1071
+ <div className="flex items-center gap-2 py-1.5 px-2 -mx-2">
629
1072
  <span className="text-xs text-text-muted font-medium">{action.label}</span>
630
1073
  {action.summary && (
631
1074
  <span className="text-[10px] text-text-muted/60 ml-1">{action.summary}</span>
632
1075
  )}
633
- </button>
634
- {isExpanded && (
635
- <div className="pl-6 pb-2 pt-1">
636
- <Markdown
637
- content={content}
638
- className="text-xs text-text-secondary leading-relaxed"
639
- />
640
- </div>
641
- )}
1076
+ </div>
1077
+ <div className="pl-6 pb-2 pt-1">
1078
+ <Markdown
1079
+ content={content}
1080
+ className="text-xs text-text-secondary leading-relaxed"
1081
+ />
1082
+ </div>
642
1083
  </div>
643
1084
  )
644
1085
  }
645
-
1086
+
646
1087
  const content = action.messages.map(m => m.content).join('\n---\n')
647
1088
  const hasContent = content.trim().length > 0
648
-
1089
+ const isActive = action.messages.some((message) => isInProgressStatus(message.meta?.status))
1090
+
1091
+ const commandRows =
1092
+ action.type === 'explored' || action.type === 'ran'
1093
+ ? buildCommandActionRows(action.messages)
1094
+ : []
1095
+ const fileStats = action.type === 'edited' ? buildFileChangeStats(action.messages) : []
1096
+ const fileSummary = action.type === 'edited' ? summarizeFileChanges(fileStats) : null
1097
+
1098
+ const label =
1099
+ action.type === 'explored'
1100
+ ? isActive ? 'Exploring' : 'Explored'
1101
+ : action.type === 'ran'
1102
+ ? isActive ? 'Running' : 'Ran'
1103
+ : action.type === 'edited'
1104
+ ? isActive ? 'Editing' : (fileSummary?.verb ?? 'Edited')
1105
+ : action.label
1106
+
1107
+ const summaryCandidate =
1108
+ action.type === 'edited'
1109
+ ? fileSummary?.summary
1110
+ : action.type === 'ran'
1111
+ ? action.messages.find((message) => message.meta?.command)?.meta?.command ||
1112
+ commandRows.find((row) => row.label === 'Run')?.detail ||
1113
+ action.summary
1114
+ : undefined
1115
+
1116
+ const summary = summaryCandidate && !isStatusText(summaryCandidate) ? summaryCandidate : undefined
1117
+
1118
+ const detailRows =
1119
+ action.type === 'explored'
1120
+ ? commandRows
1121
+ : action.type === 'edited'
1122
+ ? fileSummary?.rows ?? []
1123
+ : []
1124
+
1125
+ const maxRows = 4
1126
+ const canExpand = hasContent || detailRows.length > maxRows
1127
+ const visibleRows = isExpanded ? detailRows : detailRows.slice(0, maxRows)
1128
+ const hiddenCount = detailRows.length - visibleRows.length
1129
+
649
1130
  const getIcon = () => {
650
1131
  switch (action.type) {
651
1132
  case 'explored': return <Icons.Search className="w-3 h-3 text-text-muted/60" />
@@ -655,20 +1136,43 @@ function ActionRow({ action }: { action: AssistantAction }) {
655
1136
  default: return null
656
1137
  }
657
1138
  }
658
-
1139
+
659
1140
  return (
660
1141
  <div>
661
1142
  <button
662
- onClick={() => hasContent && setIsExpanded(!isExpanded)}
1143
+ onClick={() => canExpand && setIsExpanded(!isExpanded)}
663
1144
  className={`flex items-center gap-2 py-1.5 rounded px-2 -mx-2 transition-colors w-full text-left
664
- ${hasContent ? 'hover:bg-bg-hover/30 cursor-pointer' : 'cursor-default'}`}
1145
+ ${canExpand ? 'hover:bg-bg-hover/30 cursor-pointer' : 'cursor-default'}`}
665
1146
  >
666
1147
  {getIcon()}
667
- <span className="text-xs text-text-primary font-medium">{action.label}</span>
668
- {action.summary && (
669
- <span className="text-xs text-text-muted ml-1 truncate max-w-[300px]">{action.summary}</span>
1148
+ {isActive ? (
1149
+ <ShimmerText text={label} className="text-xs text-text-primary font-medium" />
1150
+ ) : (
1151
+ <span className="text-xs text-text-primary font-medium">{label}</span>
1152
+ )}
1153
+ {summary && (
1154
+ <span className="text-xs text-text-muted ml-1 truncate max-w-[320px]">{summary}</span>
1155
+ )}
1156
+ {canExpand && (
1157
+ <Icons.ChevronDown
1158
+ className={`w-3 h-3 text-text-muted ml-auto transition-transform duration-200 ${isExpanded ? '' : '-rotate-90'}`}
1159
+ />
670
1160
  )}
671
1161
  </button>
1162
+ {detailRows.length > 0 && (
1163
+ <div className="pl-6 pb-1 pt-0.5 space-y-0.5">
1164
+ {visibleRows.map((row, index) => (
1165
+ <div key={`${row.label}-${index}`} className="text-xs text-text-secondary">
1166
+ <span className="text-text-muted">{row.label}</span>
1167
+ <span className="text-text-muted/60"> · </span>
1168
+ <span>{row.detail}</span>
1169
+ </div>
1170
+ ))}
1171
+ {!isExpanded && hiddenCount > 0 && (
1172
+ <div className="text-[10px] text-text-muted">+{hiddenCount} more</div>
1173
+ )}
1174
+ </div>
1175
+ )}
672
1176
  {isExpanded && hasContent && (
673
1177
  <div className="pl-6 pb-2 pt-1">
674
1178
  <pre className="text-xs text-text-secondary whitespace-pre-wrap leading-relaxed font-mono bg-bg-primary/50 rounded p-2 max-h-[300px] overflow-y-auto">