better-codex 0.1.3 → 0.2.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.
@@ -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,15 +31,348 @@ 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() ?? ''
44
377
  if (title.includes('web search')) return 'searched'
45
378
  if (title.includes('read') || title.includes('view') || title.includes('list')) return 'explored'
@@ -110,6 +443,15 @@ function getActionLabel(type: AssistantAction['type'], messages: Message[]): { l
110
443
  }
111
444
  }
112
445
 
446
+ const extractReasoningHeadline = (content: string) => {
447
+ const match = content.match(/(?:^|\n)\s*\*\*(.+?)\*\*/u)
448
+ if (match?.[1]) {
449
+ return match[1].trim()
450
+ }
451
+ const firstLine = content.split('\n').map((line) => line.trim()).find(Boolean)
452
+ return firstLine || null
453
+ }
454
+
113
455
  function groupMessagesIntoTurns(messages: Message[]): Turn[] {
114
456
  const turns: Turn[] = []
115
457
  let currentTurn: Turn | null = null
@@ -231,30 +573,57 @@ export function VirtualizedMessageList({
231
573
  }: VirtualizedMessageListProps) {
232
574
  const parentRef = useRef<HTMLDivElement>(null)
233
575
  const [userHasScrolled, setUserHasScrolled] = useState(false)
576
+ const [listHeight, setListHeight] = useState(0)
234
577
  const lastScrollTop = useRef(0)
235
578
  const isAutoScrolling = useRef(false)
236
579
  const prevItemsLength = useRef(0)
237
-
238
580
  const lastMessage = messages[messages.length - 1]
581
+ const userInteractedRef = useRef(false)
582
+ const seenItemIds = useRef(new Set<string>())
583
+ const lastMessageSignature = useMemo(() => {
584
+ if (!lastMessage) {
585
+ return ''
586
+ }
587
+ return `${lastMessage.id}:${lastMessage.content.length}`
588
+ }, [lastMessage])
589
+
239
590
  const isWaitingForResponse = threadStatus === 'active' && lastMessage?.role === 'user'
240
591
  const isTaskRunning = threadStatus === 'active'
241
592
 
242
593
  const turns = useMemo(() => groupMessagesIntoTurns(messages), [messages])
594
+ const workingBarHeight = 64
595
+ const baseBuffer = listHeight ? Math.round(listHeight * 0.3) : 0
596
+ const extraBuffer = Math.min(360, Math.max(120, baseBuffer))
597
+ const bottomSpacerHeight = extraBuffer + (isTaskRunning ? workingBarHeight : 0)
598
+ const activeReasoningHeadline = useMemo(() => {
599
+ if (!isTaskRunning) {
600
+ return null
601
+ }
602
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
603
+ const message = messages[i]
604
+ if (message.kind !== 'reasoning' || !message.content.trim()) {
605
+ continue
606
+ }
607
+ const headline = extractReasoningHeadline(message.content)
608
+ if (headline) {
609
+ return headline
610
+ }
611
+ }
612
+ return null
613
+ }, [isTaskRunning, messages])
243
614
 
244
615
  const items: Array<
245
616
  | { type: 'turn'; data: Turn }
246
617
  | { type: 'approval'; data: ApprovalRequest }
247
- | { type: 'thinking'; data: null }
248
- | { type: 'working'; data: { startedAt: number } }
249
618
  | { type: 'worked'; data: { duration: number } }
250
619
  | { type: 'queued'; data: QueuedMessage }
620
+ | { type: 'spacer'; data: { height: number } }
251
621
  > = [
252
622
  ...turns.map(t => ({ type: 'turn' as const, data: t })),
253
623
  ...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
624
  ...(!isTaskRunning && lastTurnDuration ? [{ type: 'worked' as const, data: { duration: lastTurnDuration } }] : []),
257
625
  ...queuedMessages.map(q => ({ type: 'queued' as const, data: q })),
626
+ ...(bottomSpacerHeight > 0 ? [{ type: 'spacer' as const, data: { height: bottomSpacerHeight } }] : []),
258
627
  ]
259
628
 
260
629
  const initialScrollDone = useRef(false)
@@ -265,10 +634,9 @@ export function VirtualizedMessageList({
265
634
  estimateSize: (index) => {
266
635
  const item = items[index]
267
636
  if (item.type === 'approval') return 140
268
- if (item.type === 'thinking') return 60
269
- if (item.type === 'working') return 60
270
637
  if (item.type === 'worked') return 40
271
638
  if (item.type === 'queued') return 80
639
+ if (item.type === 'spacer') return item.data.height
272
640
  const turn = item.data as Turn
273
641
  const userHeight = turn.userMessage ? 80 : 0
274
642
  const actionsHeight = turn.assistantActions.reduce((acc, action) => {
@@ -289,12 +657,19 @@ export function VirtualizedMessageList({
289
657
  const { scrollTop, scrollHeight, clientHeight } = parentRef.current
290
658
  const isAtBottom = scrollHeight - scrollTop - clientHeight < 50
291
659
 
292
- if (scrollTop < lastScrollTop.current && !isAtBottom) {
293
- setUserHasScrolled(true)
660
+ if (!userInteractedRef.current) {
661
+ if (isAtBottom) {
662
+ setUserHasScrolled(false)
663
+ }
664
+ lastScrollTop.current = scrollTop
665
+ return
294
666
  }
295
-
296
- if (isAtBottom) {
667
+
668
+ if (!isAtBottom) {
669
+ setUserHasScrolled(true)
670
+ } else {
297
671
  setUserHasScrolled(false)
672
+ userInteractedRef.current = false
298
673
  }
299
674
 
300
675
  lastScrollTop.current = scrollTop
@@ -309,6 +684,23 @@ export function VirtualizedMessageList({
309
684
  }
310
685
  }, [items.length, virtualizer])
311
686
 
687
+ useEffect(() => {
688
+ const element = parentRef.current
689
+ if (!element) {
690
+ return
691
+ }
692
+ const updateHeight = () => {
693
+ setListHeight(element.clientHeight)
694
+ }
695
+ updateHeight()
696
+ if (typeof ResizeObserver === 'undefined') {
697
+ return
698
+ }
699
+ const observer = new ResizeObserver(() => updateHeight())
700
+ observer.observe(element)
701
+ return () => observer.disconnect()
702
+ }, [])
703
+
312
704
  useEffect(() => {
313
705
  const hasNewItems = items.length > prevItemsLength.current
314
706
  prevItemsLength.current = items.length
@@ -322,6 +714,19 @@ export function VirtualizedMessageList({
322
714
  }
323
715
  }, [items.length, virtualizer, userHasScrolled])
324
716
 
717
+ useEffect(() => {
718
+ if (!isTaskRunning || userHasScrolled || !initialScrollDone.current || items.length === 0) {
719
+ return
720
+ }
721
+ isAutoScrolling.current = true
722
+ requestAnimationFrame(() => {
723
+ virtualizer.scrollToIndex(items.length - 1, { align: 'end' })
724
+ setTimeout(() => {
725
+ isAutoScrolling.current = false
726
+ }, 200)
727
+ })
728
+ }, [isTaskRunning, userHasScrolled, lastMessageSignature, items.length, virtualizer])
729
+
325
730
  const scrollToBottom = useCallback(() => {
326
731
  if (items.length > 0) {
327
732
  setUserHasScrolled(false)
@@ -345,9 +750,18 @@ export function VirtualizedMessageList({
345
750
  <div className="flex-1 relative">
346
751
  <div
347
752
  ref={parentRef}
348
- className="h-full overflow-y-auto px-3 md:px-6 touch-scroll"
753
+ className="h-full overflow-y-auto px-3 md:px-6 touch-scroll pb-4"
349
754
  style={{ contain: 'strict' }}
350
755
  onScroll={handleScroll}
756
+ onWheel={() => {
757
+ userInteractedRef.current = true
758
+ }}
759
+ onTouchMove={() => {
760
+ userInteractedRef.current = true
761
+ }}
762
+ onMouseDown={() => {
763
+ userInteractedRef.current = true
764
+ }}
351
765
  >
352
766
  <div
353
767
  className="max-w-4xl mx-auto relative"
@@ -355,11 +769,29 @@ export function VirtualizedMessageList({
355
769
  >
356
770
  {virtualizer.getVirtualItems().map((virtualItem) => {
357
771
  const item = items[virtualItem.index]
772
+ const itemKey =
773
+ item.type === 'turn'
774
+ ? `turn:${item.data.id}`
775
+ : item.type === 'approval'
776
+ ? `approval:${item.data.id}`
777
+ : item.type === 'queued'
778
+ ? `queued:${item.data.id}`
779
+ : item.type === 'worked'
780
+ ? `worked:${item.data.duration}`
781
+ : null
782
+ const isFresh = itemKey ? !seenItemIds.current.has(itemKey) : false
783
+ if (itemKey) {
784
+ seenItemIds.current.add(itemKey)
785
+ }
786
+ const shouldAnimate = isFresh && !userHasScrolled && initialScrollDone.current && item.type !== 'spacer'
787
+ const wrapperClass = item.type === 'spacer'
788
+ ? 'absolute top-0 left-0 w-full'
789
+ : `absolute top-0 left-0 w-full py-2${shouldAnimate ? ' animate-in fade-in duration-200' : ''}`
358
790
 
359
791
  return (
360
792
  <div
361
793
  key={virtualItem.key}
362
- className="absolute top-0 left-0 w-full py-2"
794
+ className={wrapperClass}
363
795
  style={{
364
796
  transform: `translateY(${virtualItem.start}px)`,
365
797
  }}
@@ -368,14 +800,12 @@ export function VirtualizedMessageList({
368
800
  >
369
801
  {item.type === 'turn' ? (
370
802
  <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
803
  ) : item.type === 'worked' ? (
376
804
  <WorkedBubble duration={item.data.duration} />
377
805
  ) : item.type === 'queued' ? (
378
806
  <QueuedMessageBubble message={item.data} />
807
+ ) : item.type === 'spacer' ? (
808
+ <div style={{ height: item.data.height }} />
379
809
  ) : (
380
810
  <ApprovalCard
381
811
  approval={item.data as ApprovalRequest}
@@ -390,13 +820,21 @@ export function VirtualizedMessageList({
390
820
  </div>
391
821
  </div>
392
822
 
823
+ {isTaskRunning && (
824
+ <StickyWorkingBar
825
+ message={activeReasoningHeadline ?? (isWaitingForResponse ? 'Thinking' : 'Working')}
826
+ startedAt={turnStartedAt ?? null}
827
+ onInterrupt={onInterrupt}
828
+ />
829
+ )}
830
+
393
831
  {userHasScrolled && (
394
832
  <button
395
833
  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"
834
+ className={`absolute ${isTaskRunning ? 'bottom-20' : 'bottom-4'} left-1/2 -translate-x-1/2
835
+ flex items-center gap-1.5 px-3 py-1.5 bg-bg-elevated border border-border rounded-full
836
+ text-xs text-text-muted hover:text-text-primary hover:border-text-muted transition-all
837
+ shadow-lg animate-in fade-in slide-in-from-bottom-2 duration-200`}
400
838
  >
401
839
  <Icons.ChevronDown className="w-3 h-3" />
402
840
  <span>New messages</span>
@@ -406,48 +844,53 @@ export function VirtualizedMessageList({
406
844
  )
407
845
  }
408
846
 
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>
847
+ function StickyWorkingBar({
848
+ message,
849
+ startedAt,
850
+ onInterrupt,
851
+ }: {
852
+ message: string
853
+ startedAt: number | null
854
+ onInterrupt?: () => void
855
+ }) {
856
+ const [elapsed, setElapsed] = useState(() =>
857
+ startedAt ? Math.floor((Date.now() - startedAt) / 1000) : 0
425
858
  )
426
- }
427
859
 
428
- function WorkingBubble({ startedAt, onInterrupt }: { startedAt: number; onInterrupt?: () => void }) {
429
- const [elapsed, setElapsed] = useState(() => Math.floor((Date.now() - startedAt) / 1000))
430
-
431
860
  useEffect(() => {
861
+ if (!startedAt) {
862
+ return
863
+ }
432
864
  const interval = setInterval(() => {
433
865
  setElapsed(Math.floor((Date.now() - startedAt) / 1000))
434
866
  }, 1000)
435
867
  return () => clearInterval(interval)
436
868
  }, [startedAt])
437
-
869
+
870
+ const formatElapsed = (secs: number) => {
871
+ if (secs < 60) return `${secs}s`
872
+ const mins = Math.floor(secs / 60)
873
+ const remaining = secs % 60
874
+ return `${mins}m ${remaining.toString().padStart(2, '0')}s`
875
+ }
876
+
438
877
  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
- )}
878
+ <div className="absolute bottom-0 left-0 right-0 px-3 md:px-6 pb-3">
879
+ <div className="max-w-4xl mx-auto">
880
+ <div className="flex items-center gap-2 bg-bg-elevated/90 border border-border rounded-full px-4 py-2 shadow-lg backdrop-blur">
881
+ <ShimmerText text={message} className="text-xs text-text-primary font-medium" />
882
+ {startedAt !== null && (
883
+ <span className="text-[10px] text-text-muted">({formatElapsed(elapsed)})</span>
884
+ )}
885
+ {onInterrupt && (
886
+ <button
887
+ onClick={onInterrupt}
888
+ className="ml-auto px-2 py-0.5 text-[10px] text-text-muted hover:text-error hover:bg-error/10 rounded transition-colors"
889
+ >
890
+ Stop
891
+ </button>
892
+ )}
893
+ </div>
451
894
  </div>
452
895
  </div>
453
896
  )
@@ -608,8 +1051,7 @@ function ActionRow({ action }: { action: AssistantAction }) {
608
1051
 
609
1052
  if (isPlaceholder) {
610
1053
  return (
611
- <div className="flex items-center gap-2 py-1.5">
612
- <span className="text-text-muted">•</span>
1054
+ <div className="flex items-center gap-2 py-1.5 pl-2">
613
1055
  <ThinkingIndicator message="Thinking" />
614
1056
  </div>
615
1057
  )
@@ -617,35 +1059,65 @@ function ActionRow({ action }: { action: AssistantAction }) {
617
1059
 
618
1060
  return (
619
1061
  <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
- />
1062
+ <div className="flex items-center gap-2 py-1.5 px-2 -mx-2">
629
1063
  <span className="text-xs text-text-muted font-medium">{action.label}</span>
630
1064
  {action.summary && (
631
1065
  <span className="text-[10px] text-text-muted/60 ml-1">{action.summary}</span>
632
1066
  )}
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
- )}
1067
+ </div>
1068
+ <div className="pl-6 pb-2 pt-1">
1069
+ <Markdown
1070
+ content={content}
1071
+ className="text-xs text-text-secondary leading-relaxed"
1072
+ />
1073
+ </div>
642
1074
  </div>
643
1075
  )
644
1076
  }
645
-
1077
+
646
1078
  const content = action.messages.map(m => m.content).join('\n---\n')
647
1079
  const hasContent = content.trim().length > 0
648
-
1080
+ const isActive = action.messages.some((message) => isInProgressStatus(message.meta?.status))
1081
+
1082
+ const commandRows =
1083
+ action.type === 'explored' || action.type === 'ran'
1084
+ ? buildCommandActionRows(action.messages)
1085
+ : []
1086
+ const fileStats = action.type === 'edited' ? buildFileChangeStats(action.messages) : []
1087
+ const fileSummary = action.type === 'edited' ? summarizeFileChanges(fileStats) : null
1088
+
1089
+ const label =
1090
+ action.type === 'explored'
1091
+ ? isActive ? 'Exploring' : 'Explored'
1092
+ : action.type === 'ran'
1093
+ ? isActive ? 'Running' : 'Ran'
1094
+ : action.type === 'edited'
1095
+ ? isActive ? 'Editing' : (fileSummary?.verb ?? 'Edited')
1096
+ : action.label
1097
+
1098
+ const summaryCandidate =
1099
+ action.type === 'edited'
1100
+ ? fileSummary?.summary
1101
+ : action.type === 'ran'
1102
+ ? action.messages.find((message) => message.meta?.command)?.meta?.command ||
1103
+ commandRows.find((row) => row.label === 'Run')?.detail ||
1104
+ action.summary
1105
+ : undefined
1106
+
1107
+ const summary = summaryCandidate && !isStatusText(summaryCandidate) ? summaryCandidate : undefined
1108
+
1109
+ const detailRows =
1110
+ action.type === 'explored'
1111
+ ? commandRows
1112
+ : action.type === 'edited'
1113
+ ? fileSummary?.rows ?? []
1114
+ : []
1115
+
1116
+ const maxRows = 4
1117
+ const canExpand = hasContent || detailRows.length > maxRows
1118
+ const visibleRows = isExpanded ? detailRows : detailRows.slice(0, maxRows)
1119
+ const hiddenCount = detailRows.length - visibleRows.length
1120
+
649
1121
  const getIcon = () => {
650
1122
  switch (action.type) {
651
1123
  case 'explored': return <Icons.Search className="w-3 h-3 text-text-muted/60" />
@@ -655,20 +1127,43 @@ function ActionRow({ action }: { action: AssistantAction }) {
655
1127
  default: return null
656
1128
  }
657
1129
  }
658
-
1130
+
659
1131
  return (
660
1132
  <div>
661
1133
  <button
662
- onClick={() => hasContent && setIsExpanded(!isExpanded)}
1134
+ onClick={() => canExpand && setIsExpanded(!isExpanded)}
663
1135
  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'}`}
1136
+ ${canExpand ? 'hover:bg-bg-hover/30 cursor-pointer' : 'cursor-default'}`}
665
1137
  >
666
1138
  {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>
1139
+ {isActive ? (
1140
+ <ShimmerText text={label} className="text-xs text-text-primary font-medium" />
1141
+ ) : (
1142
+ <span className="text-xs text-text-primary font-medium">{label}</span>
1143
+ )}
1144
+ {summary && (
1145
+ <span className="text-xs text-text-muted ml-1 truncate max-w-[320px]">{summary}</span>
1146
+ )}
1147
+ {canExpand && (
1148
+ <Icons.ChevronDown
1149
+ className={`w-3 h-3 text-text-muted ml-auto transition-transform duration-200 ${isExpanded ? '' : '-rotate-90'}`}
1150
+ />
670
1151
  )}
671
1152
  </button>
1153
+ {detailRows.length > 0 && (
1154
+ <div className="pl-6 pb-1 pt-0.5 space-y-0.5">
1155
+ {visibleRows.map((row, index) => (
1156
+ <div key={`${row.label}-${index}`} className="text-xs text-text-secondary">
1157
+ <span className="text-text-muted">{row.label}</span>
1158
+ <span className="text-text-muted/60"> · </span>
1159
+ <span>{row.detail}</span>
1160
+ </div>
1161
+ ))}
1162
+ {!isExpanded && hiddenCount > 0 && (
1163
+ <div className="text-[10px] text-text-muted">+{hiddenCount} more</div>
1164
+ )}
1165
+ </div>
1166
+ )}
672
1167
  {isExpanded && hasContent && (
673
1168
  <div className="pl-6 pb-2 pt-1">
674
1169
  <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">