better-codex 0.1.4 → 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.
- package/apps/backend/README.md +43 -2
- package/apps/backend/src/core/app-server.ts +68 -2
- package/apps/backend/src/server.ts +156 -1
- package/apps/backend/src/services/codex-config.ts +561 -0
- package/apps/backend/src/thread-activity/service.ts +47 -0
- package/apps/web/README.md +18 -2
- package/apps/web/src/components/layout/codex-settings.tsx +1208 -0
- package/apps/web/src/components/layout/settings-dialog.tsx +9 -1
- package/apps/web/src/components/layout/virtualized-message-list.tsx +581 -86
- package/apps/web/src/hooks/use-hub-connection.ts +21 -3
- package/apps/web/src/hooks/use-thread-history.ts +94 -5
- package/apps/web/src/services/hub-client.ts +98 -1
- package/apps/web/src/types/index.ts +24 -0
- package/apps/web/src/utils/item-format.ts +55 -9
- package/package.json +1 -1
|
@@ -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('
|
|
39
|
-
return '
|
|
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 (
|
|
293
|
-
|
|
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=
|
|
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=
|
|
397
|
-
bg-bg-elevated border border-border rounded-full
|
|
398
|
-
hover:text-text-primary hover:border-text-muted transition-all
|
|
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
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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="
|
|
440
|
-
<div className="
|
|
441
|
-
<
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
634
|
-
|
|
635
|
-
<
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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={() =>
|
|
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
|
-
${
|
|
1136
|
+
${canExpand ? 'hover:bg-bg-hover/30 cursor-pointer' : 'cursor-default'}`}
|
|
665
1137
|
>
|
|
666
1138
|
{getIcon()}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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">
|