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.
- package/apps/backend/README.md +43 -2
- package/apps/backend/src/core/app-server.ts +68 -2
- package/apps/backend/src/core/jsonrpc.ts +13 -0
- 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/session-view.tsx +203 -8
- package/apps/web/src/components/layout/settings-dialog.tsx +9 -1
- package/apps/web/src/components/layout/virtualized-message-list.tsx +594 -90
- package/apps/web/src/components/session-view/rate-limit-banner.tsx +178 -0
- package/apps/web/src/components/session-view/session-dialogs.tsx +93 -2
- package/apps/web/src/components/session-view/session-header.tsx +21 -2
- package/apps/web/src/components/session-view/thread-account-switcher.tsx +191 -0
- package/apps/web/src/components/ui/icons.tsx +12 -0
- package/apps/web/src/hooks/use-hub-connection.ts +59 -19
- 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/store/index.ts +36 -1
- package/apps/web/src/types/index.ts +25 -0
- package/apps/web/src/utils/item-format.ts +55 -9
- package/apps/web/src/utils/slash-commands.ts +2 -0
- 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,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('
|
|
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() ?? ''
|
|
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 (
|
|
293
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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
|
|
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
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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="
|
|
440
|
-
<div className="
|
|
441
|
-
<
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
634
|
-
|
|
635
|
-
<
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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={() =>
|
|
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
|
-
${
|
|
1145
|
+
${canExpand ? 'hover:bg-bg-hover/30 cursor-pointer' : 'cursor-default'}`}
|
|
665
1146
|
>
|
|
666
1147
|
{getIcon()}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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">
|