Repository navigation
Expand file tree
/
Copy pathMessageRenderer.tsx
More file actions
1341 lines (1220 loc) · 48.7 KB
/
Copy pathMessageRenderer.tsx
File metadata and controls
1341 lines (1220 loc) · 48.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { memo, useState, useRef, useEffect, useLayoutEffect, useMemo, useCallback, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { diffLines } from 'diff'
import { animate } from 'motion/mini'
import { ChevronDownIcon, ChevronRightIcon } from '../../components/Icons'
import { CopyButton, SmoothHeight } from '../../components/ui'
import { useCompositorExpand, useDisclosureScrollLock } from '../../hooks'
import { useNow } from '../../hooks/useNow'
import { useTheme } from '../../hooks/useTheme'
import { useDirectory } from '../../hooks'
import { followShellOutput } from './shellOutput'
import {
useInlineToolRequests,
findPermissionRequestForTool,
findQuestionRequestForTool,
type TaskChildSessionRef,
} from '../chat/InlineToolRequestContext'
import {
TextPartView,
ReasoningPartView,
ToolPartView,
FilePartView,
AgentPartView,
StepFinishPartView,
RetryPartView,
CompactionPartView,
MessageErrorView,
type StepFinishInfo,
} from './parts'
import { CollapsibleUserText, USER_HTML_ARTIFACT_PATTERN } from './CollapsibleUserText'
import { extractToolData, type ToolViewPart } from './tools'
import { ForkActionButton, UndoActionButton, useMessageActionBarClass } from './MessageActionBar'
import { MSG_SPACING } from './messageSpacing'
import { MessageExpandPanel, useMessageExpandRender } from './messageExpand'
import type { AssistantContent, AssistantMessage, SessionMessageInfo, UserMessage } from '../../types/api/message'
import type { JsonValue } from '@opencode/client/promise'
import { contentEntries, hasVisibleText, idleMessageError, userMessageText, type ContentEntry } from '../../types/api/message'
import { currentToolFailed } from '../../types/api/toolState'
import { resolveTaskChildSessionRef } from '../../utils/taskChildBinding'
import { expandCodeModeSubCalls } from './parts/executeSubCallSummary'
import {
ENTRY_GROW_DURATION_MS,
isEntryGrowComplete,
markEntryGrowComplete,
shouldPlayEntryGrow,
} from '../../utils/entryGrow'
import {
formatDuration,
formatProcessDuration,
formatCompletedAt,
formatDetailedDateTime,
} from '../../utils/formatUtils'
import { lockScrollAroundAnchor } from '../../utils/scrollUtils'
import { useUiDisclosureState } from '../../utils/uiDisclosureState'
/**
* 过程折叠 header:进行中自己走表,只有这一行因计时更新;children 不跟时钟走。
*/
const ProcessCollapseHeader = memo(function ProcessCollapseHeader({
isActive,
startedAt,
durationMs,
expanded,
onToggle,
headerRef,
}: {
isActive: boolean
startedAt?: number
durationMs?: number
expanded: boolean
onToggle: () => void
headerRef: React.RefObject<HTMLButtonElement | null>
}) {
const { t } = useTranslation('message')
const now = useNow(1000, isActive && startedAt != null)
const liveMs = isActive && startedAt != null ? Math.max(0, now - startedAt) : null
const lastLiveMsRef = useRef(0)
if (liveMs != null) lastLiveMsRef.current = liveMs
const displayMs = liveMs != null ? liveMs : durationMs != null && durationMs > 0 ? durationMs : lastLiveMsRef.current
// Working/Worked:整秒无小数;超过 1 分钟带 m(如 3m 12s)
const durationLabel = formatProcessDuration(displayMs)
const label = isActive
? t('processingWithDuration', { duration: durationLabel })
: t('processedFor', { duration: durationLabel })
return (
<button
ref={headerRef}
type="button"
onClick={onToggle}
className={`flex w-full items-center gap-1.5 rounded-md ${MSG_SPACING.header} text-left text-[length:var(--fs-sm)] leading-5 text-text-400 hover:bg-bg-200/30 hover:text-text-200 transition-colors`}
>
<span className={isActive ? 'reasoning-shimmer-text' : 'text-text-400'}>{label}</span>
<span className="inline-flex items-center justify-center text-text-500">
{expanded ? <ChevronDownIcon size={12} /> : <ChevronRightIcon size={12} />}
</span>
</button>
)
})
/** 过程折叠块:动画与 tool steps 一致(grid-rows + delayed body) */
export function ProcessCollapseBlock({
children,
durationMs,
startedAt,
isActive,
stateKey,
}: {
children: ReactNode
durationMs?: number
startedAt?: number
isActive: boolean
stateKey: string
}) {
const [expanded, setExpanded] = useUiDisclosureState(stateKey, isActive)
const shouldRenderBody = useMessageExpandRender(expanded)
const rootRef = useRef<HTMLDivElement>(null)
const headerRef = useRef<HTMLButtonElement>(null)
const unlockScrollRef = useRef<(() => void) | null>(null)
useEffect(() => {
setExpanded(isActive, { touched: false, respectUser: true })
}, [isActive, setExpanded])
useEffect(() => {
return () => {
unlockScrollRef.current?.()
unlockScrollRef.current = null
}
}, [])
const toggleExpanded = useCallback(() => {
unlockScrollRef.current?.()
unlockScrollRef.current = lockScrollAroundAnchor(headerRef.current, {
observe: rootRef.current,
})
setExpanded(!expanded)
}, [expanded, setExpanded])
// 进行中默认展开:不要跑 grid 展开动画(否则每条新消息都带动画高度重排)
// 用户手动折叠/展开、或结束后自动收起时再开动画
// 挂载本身不另做入场生长——像普通消息一样直接出现
const animateGrid = !isActive || expanded !== isActive
return (
<div ref={rootRef} className="flex flex-col">
<ProcessCollapseHeader
isActive={isActive}
startedAt={startedAt}
durationMs={durationMs}
expanded={expanded}
onToggle={toggleExpanded}
headerRef={headerRef}
/>
<MessageExpandPanel open={expanded} animate={animateGrid} clip>
{shouldRenderBody && <div className={MSG_SPACING.processBody}>{children}</div>}
</MessageExpandPanel>
</div>
)
}
/**
* 消息内容范围:
* - all: 正常渲染
* - process: 只渲染过程部分(进外层折叠块)
* - final: 只渲染尾部最终 text
* - inline: 完整渲染(已在外层过程块内)
*/
export type ProcessContentScope = 'all' | 'process' | 'final' | 'inline'
type ProcessSplit = {
processItems: RenderItem[]
finalItems: RenderItem[]
hasProcess: boolean
hasFinal: boolean
}
// ============================================
// 渲染条目
//
// v2 的助手内容只有 text / reasoning / tool 三类,且元素**没有 id**:
// 稳定 id 一律由 `contentEntries()` 按官方算法派生
// (text/reasoning 用 `${message.id}:${type}:${perKindOrdinal}`,tool 用 `content.id`)。
// ============================================
/** 派生 id + 原生内容 */
type ToolEntry = ContentEntry<Extract<AssistantContent, { type: 'tool' }>>
type RenderItem =
| { type: 'single'; id: string; content: Exclude<AssistantContent, { type: 'tool' }> }
| { type: 'tool-group'; tools: ToolEntry[] }
/**
* 把 render items 拆成「过程」和「最终回答」。
* 最终回答 = 消息中最后一段连续 text。
* reasoning / tool 永远进过程。
*/
export function splitProcessRenderItems(items: RenderItem[]): ProcessSplit {
if (items.length === 0) {
return { processItems: [], finalItems: [], hasProcess: false, hasFinal: false }
}
let lastTextIdx = -1
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i]
if (item.type === 'single' && item.content.type === 'text') {
lastTextIdx = i
break
}
break
}
if (lastTextIdx < 0) {
return {
processItems: items,
finalItems: [],
hasProcess: items.length > 0,
hasFinal: false,
}
}
let textRunStart = lastTextIdx
while (textRunStart > 0) {
const prev = items[textRunStart - 1]
if (prev.type === 'single' && prev.content.type === 'text') {
textRunStart -= 1
continue
}
break
}
const finalItems = items.slice(textRunStart)
const processItems = items.slice(0, textRunStart)
return {
processItems,
finalItems,
hasProcess: processItems.length > 0,
hasFinal: finalItems.length > 0,
}
}
/** v2 没有 isStreaming 标志:助手消息 time.completed 缺失即仍在流式 */
export function messageStillStreamingProcess(message: SessionMessageInfo): boolean {
if (message.type !== 'assistant') return false
return message.time.completed == null
}
/** 是否有可收进过程块的内容(tool / reasoning 等,不含尾部最终 text) */
export function messageHasProcessContent(message: SessionMessageInfo): boolean {
if (message.type !== 'assistant') return false
if (messageStillStreamingProcess(message)) return true
return splitProcessRenderItems(groupContentForRender(message)).hasProcess
}
/** 是否有应留在折叠块外的最终 text(仅消息已结束后才拆) */
export function messageHasFinalContent(message: SessionMessageInfo): boolean {
if (message.type !== 'assistant') return false
if (messageStillStreamingProcess(message)) return false
return splitProcessRenderItems(groupContentForRender(message)).hasFinal
}
interface MessageRendererProps {
message: SessionMessageInfo
allowStreamingLayoutAnimation?: boolean
/** 回合总时长(毫秒),仅在回合最后一条 assistant 消息上有值 */
turnDuration?: number
/**
* 是否为该用户回合的最后一条可见 assistant。
* latestOnly 开启时,中间 assistant 不显示 step 完成信息。
* 未传入时按 true 处理(单条消息场景)。
*/
isTurnLatestAssistant?: boolean
/** 过程折叠时的内容范围 */
processContentScope?: ProcessContentScope
/**
* 本条消息的 step 完成信息是否由本实例渲染。
* 过程折叠时,末尾助手的「过程」截在壳内渲染、正文在壳外单独渲染一份,
* 这时壳内那份传 false,避免同一条消息出现两行用量。
*/
stepFinishVisible?: boolean
onUndo?: (userMessageId: string) => void
onFork?: (message: SessionMessageInfo, forkMessageId?: string) => Promise<void> | void
forkMessageId?: string
canUndo?: boolean
/** 用户消息入场生长完成(供过程壳等待挂载) */
onEntryGrowComplete?: (messageId: string) => void
}
export const MessageRenderer = memo(function MessageRenderer({
message,
allowStreamingLayoutAnimation = false,
turnDuration,
isTurnLatestAssistant = true,
processContentScope = 'all',
stepFinishVisible = true,
onUndo,
onFork,
forkMessageId,
canUndo,
onEntryGrowComplete,
}: MessageRendererProps) {
if (message.type === 'user') {
return (
<UserMessageView
message={message}
onUndo={onUndo}
onFork={onFork}
forkMessageId={forkMessageId}
canUndo={canUndo}
onEntryGrowComplete={onEntryGrowComplete}
/>
)
}
// 压缩在 v2 里是**独立消息**(不是挂在用户消息上的 part)
if (message.type === 'compaction') {
return (
<div className="flex flex-col w-full">
<CompactionPartView
message={message}
onUndo={onUndo}
onFork={onFork}
forkMessageId={forkMessageId}
canUndo={canUndo}
/>
</div>
)
}
// `!` shell 命令消息(官方 SessionShellMessage:投影成 shell 工具调用)
if (message.type === 'shell') {
return <ShellMessageView message={message} />
}
// 失败的 idle 标记:会话级错误(pre-promotion failure 等)渲染成错误卡片
// (官方 session-ui projection.ts 的 isFailedIdle → Error 卡片)。
// 这类失败没有任何 assistant 消息,不渲染的话错误永远不可见。
if (message.type === 'idle') {
const idleError = idleMessageError(message)
if (message.outcome === 'failed' && idleError) {
return (
<div className="flex flex-col w-full">
<MessageErrorView error={idleError} stateKey={`message:${message.id}:error`} />
</div>
)
}
return null
}
if (message.type !== 'assistant') return null
return (
<AssistantMessageView
message={message}
allowStreamingLayoutAnimation={allowStreamingLayoutAnimation}
turnDuration={turnDuration}
isTurnLatestAssistant={isTurnLatestAssistant}
processContentScope={processContentScope}
stepFinishVisible={stepFinishVisible}
onFork={onFork}
forkMessageId={forkMessageId}
/>
)
})
// ============================================
// ShellMessageView — `!` shell 命令消息
//
// 官方 SessionShellMessage 直接复用 shell 工具渲染器
//(session-ui/tool-renderer.tsx:1837-1875):把消息投影成一次 shell
// 工具调用(input=command、output=输出文本、metadata 带 exit)。
// 这里做同样的投影,交给我们既有的 bash/shell 工具管线。
// ============================================
const ShellMessageView = memo(function ShellMessageView({ message }: { message: SessionMessageInfo & { type: 'shell' } }) {
const { t } = useTranslation('chat')
const { serverId } = useInlineToolRequests()
const { currentDirectory } = useDirectory()
// 运行中 shell 的实时输出跟随(官方 session-ui shell 渲染器同款):
// 事件流只在 shell 结束时给最终 output,存活期间靠 shell.output 游标轮询
const isRunning = message.status === 'running'
const [streamedOutput, setStreamedOutput] = useState('')
useEffect(() => {
if (!isRunning || !message.shellID) return
return followShellOutput({
id: message.shellID,
directory: currentDirectory,
serverId: serverId || undefined,
running: true,
onOutput: setStreamedOutput,
})
}, [isRunning, message.shellID, currentDirectory, serverId])
const outputText = (isRunning && streamedOutput) || message.output?.output || ''
const exit = typeof message.exit === 'number' ? message.exit : undefined
const part = useMemo<ToolViewPart>(() => {
const input = { command: message.command }
const metadata: Record<string, JsonValue> = { shellID: message.shellID }
let state: ToolViewPart['state']
if (message.status === 'running') {
state = {
status: 'running',
input,
metadata: outputText ? { ...metadata, output: outputText } : metadata,
}
} else if (message.status === 'timeout' || message.status === 'killed') {
state = {
status: 'error',
input,
error: {
type: 'ShellError',
message: t(message.status === 'timeout' ? 'shellMessage.timeout' : 'shellMessage.killed'),
},
...(outputText ? { content: [{ type: 'text' as const, text: outputText }] } : {}),
metadata,
}
} else {
if (exit !== undefined) metadata.exit = exit
state = {
status: 'completed',
input,
content: [{ type: 'text' as const, text: outputText }],
metadata,
}
}
return {
type: 'tool',
id: message.shellID,
name: 'shell',
messageID: message.id,
state,
time: { created: message.time.created, completed: message.time.completed },
}
}, [message.id, message.shellID, message.command, message.status, exit, outputText, message.time.created, message.time.completed, t])
return (
<div className="flex flex-col w-full">
<ToolPartView
part={part}
context={{ messageID: message.id, created: message.time.created, completed: message.time.completed }}
isFirst
isLast
/>
</div>
)
})
// ============================================
// 入场生长动画 hook — 新消息作为对话流的延续,从 height 0 平滑展开
// 完成后 markEntryGrowComplete,供过程壳「等用户登场完再挂」使用
// ============================================
function useEntryGrowAnimation(
created: number,
enabled = true,
completeId?: string,
onComplete?: (id: string) => void,
) {
const ref = useRef<HTMLDivElement>(null)
const onCompleteRef = useRef(onComplete)
onCompleteRef.current = onComplete
useLayoutEffect(() => {
const el = ref.current
const finish = () => {
if (!completeId) return
if (!isEntryGrowComplete(completeId)) markEntryGrowComplete(completeId)
onCompleteRef.current?.(completeId)
}
// 过程壳内消息禁止入场生长:会连着虚拟行反复 measure,造成整列高度抽搐
if (!enabled || !el) {
finish()
return
}
// 已完成过(虚拟行复用 remount)或消息太旧:不播,直接放行
if ((completeId && isEntryGrowComplete(completeId)) || !shouldPlayEntryGrow(created)) {
finish()
return
}
// 始终把 DOM 还原干净:StrictMode 开发期会双调用 effect(mount→cleanup→mount),
// 第一次的 WAAPI 动画若不被真正 cancel,会在后台跑完并把固定 height 写回 DOM,
// 而 cancel 时 finished promise 会 reject,所以无论正常结束还是被取消都要清空。
const clear = () => {
el.style.height = ''
el.style.clipPath = ''
}
const targetHeight = el.scrollHeight
el.style.height = '0px'
el.style.clipPath = 'inset(0 -100% 0 -100%)'
const controls = animate(
el,
{ height: `${targetHeight}px` },
{ duration: ENTRY_GROW_DURATION_MS / 1000, ease: 'easeOut' },
)
controls.then(clear)
return () => {
// 真停掉动画,避免后台残留写入;再兜底清一次 DOM
controls.stop()
clear()
finish()
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return ref
}
// ============================================
// Collapsible User Text
// ============================================
// ============================================
// User Message View
// ============================================
interface UserMessageViewProps {
message: UserMessage
onUndo?: (userMessageId: string) => void
onFork?: (message: SessionMessageInfo, forkMessageId?: string) => Promise<void> | void
forkMessageId?: string
canUndo?: boolean
onEntryGrowComplete?: (messageId: string) => void
}
const UserMessageView = memo(function UserMessageView({
message,
onUndo,
onFork,
forkMessageId,
canUndo,
onEntryGrowComplete,
}: UserMessageViewProps) {
const { collapseUserMessages, renderUserMarkdown } = useTheme()
const actionBarClass = useMessageActionBarClass()
const wrapperRef = useEntryGrowAnimation(message.time.created, true, message.id, onEntryGrowComplete)
// v2 用户消息自带 text / files / agents,不再有 parts 数组
const messageText = userMessageText(message)
const files = message.files ?? []
const agents = message.agents ?? []
const hasUserHtmlArtifact = renderUserMarkdown && USER_HTML_ARTIFACT_PATTERN.test(messageText)
return (
<div
ref={wrapperRef}
data-user-html-artifact={hasUserHtmlArtifact ? '' : undefined}
className={`flex flex-col items-end group ${hasUserHtmlArtifact ? 'w-full' : ''}`}
>
<div className="flex flex-col gap-1 items-end w-full">
{/* 消息文本 */}
{messageText && (
<CollapsibleUserText
text={messageText}
collapseEnabled={collapseUserMessages}
renderMarkdown={renderUserMarkdown}
messageId={message.id}
/>
)}
{/* 用户附件 */}
{(files.length > 0 || agents.length > 0) && (
<div className="mt-1 flex max-w-full min-w-0 flex-wrap gap-2 justify-end">
{files.map((file, index) => (
<FilePartView key={`file:${index}`} file={file} index={index} />
))}
{agents.map((agent, index) => (
<AgentPartView key={`agent:${index}`} agent={agent} index={index} />
))}
</div>
)}
{/* Action buttons — PC 悬浮消息显示;触控设备始终显示 */}
<div className={actionBarClass}>
<UndoActionButton messageId={message.id} onUndo={onUndo} canUndo={canUndo} />
<ForkActionButton message={message} onFork={onFork} forkMessageId={forkMessageId} />
{/* Copy button */}
{messageText && <CopyButton text={messageText} position="static" />}
</div>
</div>
</div>
)
})
// ============================================
// Assistant Message View
// ============================================
const AssistantMessageView = memo(function AssistantMessageView({
message,
allowStreamingLayoutAnimation = false,
turnDuration,
isTurnLatestAssistant = true,
processContentScope = 'all',
stepFinishVisible = true,
onFork,
forkMessageId,
}: {
message: AssistantMessage
allowStreamingLayoutAnimation?: boolean
turnDuration?: number
isTurnLatestAssistant?: boolean
processContentScope?: ProcessContentScope
stepFinishVisible?: boolean
onFork?: (message: SessionMessageInfo, forkMessageId?: string) => Promise<void> | void
forkMessageId?: string
}) {
const { t } = useTranslation('message')
const { stepFinishDisplay, completedAtFormat, actionsOnLatestAssistantOnly } = useTheme()
const isStreaming = message.time.completed == null
// 整轮最新 assistant 才允许显示 step 完成信息(latestOnly 时中间 assistant 全隐藏)
const allowStepFinishOnMessage = !stepFinishDisplay.latestOnly || isTurnLatestAssistant
// 分叉/复制:默认只在回合末尾助手消息显示,避免连续多条打断阅读
// final 位始终显示操作;process/inline 不显示(避免壳内外重复)
const showMessageActions =
processContentScope !== 'process' &&
processContentScope !== 'inline' &&
(!actionsOnLatestAssistantOnly || isTurnLatestAssistant || processContentScope === 'final')
const actionBarClass = useMessageActionBarClass()
// 壳内(process/inline)和壳外 final 都别做 height 0→N:final 也是拆分后新挂载,动画会顶布局
const allowEntryGrow = processContentScope === 'all'
const wrapperRef = useEntryGrowAnimation(message.time.created, allowEntryGrow)
// 收集连续的 tool content 合并渲染;过程折叠时按 scope 拆分
const renderItems = useMemo(() => {
const items = groupContentForRender(message)
if (processContentScope === 'all' || processContentScope === 'inline') return items
// 流式未完成:整袋当 process,不拆 final
if (message.time.completed == null) {
return processContentScope === 'process' ? items : []
}
const split = splitProcessRenderItems(items)
if (processContentScope === 'process') return split.processItems
if (processContentScope === 'final') return split.finalItems
return items
}, [message, processContentScope])
// 判断哪些 reasoning 内容已经结束(后面出现了任何其他 kind 的内容)
const endedReasoningIds = useMemo(() => {
const ended = new Set<string>()
const entries = contentEntries(message)
for (let i = 0; i < entries.length; i++) {
if (entries[i].content.type !== 'reasoning') continue
if (i + 1 < entries.length) ended.add(entries[i].id)
}
return ended
}, [message])
// 计算完整文本用于复制(缓存:content 引用未变时复用,避免流式每帧重算字符串拼接)
const fullText = useMemo(
() =>
message.content
.filter(entry => entry.type === 'text')
.map(entry => entry.text)
.join(''),
[message],
)
const hasCopyableText = fullText.trim().length > 0
// 检查消息级别错误(v2 是结构化对象)
const messageError = message.error
// 消息总耗时
const { created } = message.time
const completed = message.time.completed ?? message.time.streamed
const duration = message.time.completed != null ? message.time.completed - created : undefined
// agent / model(仅 assistant 消息)
const agent = message.agent || undefined
const modelLabel = message.model?.id || undefined
// step 完成信息(v2 一个 assistant 消息 = 一个 step,tokens / cost / finish 全在消息上)
const stepFinish = useMemo(() => toStepFinishInfo(message), [message])
const showStepFinish = allowStepFinishOnMessage && stepFinishVisible && stepFinish != null
const hasStepFinishData = stepFinish != null
const showTurnDurationFooter =
allowStepFinishOnMessage &&
!isStreaming &&
!hasStepFinishData &&
stepFinishDisplay.turnDuration &&
turnDuration != null &&
turnDuration > 0
const showCompletedAtFooter =
allowStepFinishOnMessage && !isStreaming && !hasStepFinishData && stepFinishDisplay.completedAt && completed != null
if (!isStreaming && renderItems.length === 0) {
// process/final 空内容时不占位
if (processContentScope === 'process' || processContentScope === 'final') return null
// 有错误时直接显示错误信息
if (messageError) {
return (
<div className={`flex flex-col ${MSG_SPACING.stack} w-full`}>
<MessageErrorView error={messageError} stateKey={`message:${message.id}:error`} />
</div>
)
}
return null
}
// process/final 拆完后可能为空
if (renderItems.length === 0 && processContentScope !== 'all' && processContentScope !== 'inline') {
return null
}
return (
<div ref={wrapperRef} className={`flex flex-col ${MSG_SPACING.stack} w-full group`}>
{/* 流式增高走 SmoothHeight 0.12s 补间:贴底跟随时内容丝滑生长。
不限 scope——过程壳内的流式消息(scope 'process')同样需要,
否则壳内文本每 chunk 瞬时撑开,行高抖动且丢失生长动画 */}
<SmoothHeight isActive={isStreaming && allowStreamingLayoutAnimation}>
<div className={`flex flex-col ${MSG_SPACING.stack}`}>
{renderItems.map((item: RenderItem) => {
if (item.type === 'tool-group') {
return (
<ToolGroup
key={item.tools[0].id}
tools={item.tools}
messageContext={{ messageID: message.id, created, completed }}
isStreaming={isStreaming}
/>
)
}
const content = item.content
switch (content.type) {
case 'text':
return <TextPartView key={item.id} part={content} isStreaming={isStreaming} />
case 'reasoning': {
const reasoningDone = endedReasoningIds.has(item.id)
return (
<ReasoningPartView
key={item.id}
part={content}
partID={item.id}
isStreaming={isStreaming && !reasoningDone}
/>
)
}
default:
return null
}
})}
{/* step 完成信息:v2 里一个助手消息就是一整个 step,tokens / cost / finish
全挂在消息上,所以固定放在本消息内容末尾一行。
以前挂「最后一个可见条目」:末尾是正文(最常见)时整块数据跟着丢,
只有工具组收尾才显示,过程折叠时还会藏在壳里。 */}
{stepFinish && showStepFinish && (
<StepFinishPartView
part={stepFinish}
duration={duration}
turnDuration={turnDuration}
agent={agent}
modelLabel={modelLabel}
completedAt={completed}
/>
)}
</div>
</SmoothHeight>
{/* 重试状态(v2:助手消息上的 retry 字段) */}
{message.retry && processContentScope !== 'process' && processContentScope !== 'inline' && (
<RetryPartView retry={message.retry} stateKey={`message:${message.id}:retry:${message.retry.attempt}`} />
)}
{/* Message-level error:过程壳内不重复挂错误 */}
{messageError && processContentScope !== 'process' && processContentScope !== 'inline' && (
<MessageErrorView error={messageError} stateKey={`message:${message.id}:error`} />
)}
{processContentScope !== 'process' &&
processContentScope !== 'inline' &&
(showTurnDurationFooter || showCompletedAtFooter) && (
<div className="flex items-center gap-3 py-0.5 text-[length:var(--fs-xxs)] text-text-500">
{showTurnDurationFooter && (
<span>{t('stepFinish.totalDuration', { duration: formatDuration(turnDuration!) })}</span>
)}
{showCompletedAtFooter && (
<span title={formatDetailedDateTime(completed!)}>{formatCompletedAt(completed!, completedAtFormat)}</span>
)}
</div>
)}
{showMessageActions && hasCopyableText && (
<div className={actionBarClass}>
<ForkActionButton message={message} onFork={onFork} forkMessageId={forkMessageId} />
<CopyButton text={fullText} position="static" />
</div>
)}
</div>
)
})
// ============================================
// Tool Group (连续的 tool content)
// ============================================
interface ToolGroupProps {
tools: ToolEntry[]
messageContext: { messageID: string; created: number; completed?: number }
isStreaming?: boolean
}
/** 用户需要阅读/交互的工具:沉浸模式下这些工具完成后保持展开 */
const READABLE_TOOL_PATTERNS = /bash|\bsh\b|cmd|terminal|shell|write|save|edit|replace|patch|question|ask/i
function isReadableTool(toolName: string): boolean {
return READABLE_TOOL_PATTERNS.test(toolName.toLowerCase())
}
const ToolGroup = memo(function ToolGroup({ tools, messageContext, isStreaming }: ToolGroupProps) {
const { t } = useTranslation('message')
const { descriptiveToolSteps, inlineToolRequests, immersiveMode, processCollapseEnabled } = useTheme()
const { serverId, pendingPermissions, pendingQuestions } = useInlineToolRequests()
const hasPendingInteraction =
inlineToolRequests &&
tools.some(tool => {
const childSession = getTaskChildSessionRef(tool.content, serverId)
return (
findPermissionRequestForTool(pendingPermissions, tool.content.id, childSession) ||
findQuestionRequestForTool(pendingQuestions, tool.content.id, childSession)
)
})
const doneCount = tools.filter(tool => !isToolPartActive(tool.content) && !currentToolFailed(tool.content)).length
const totalCount = tools.length
const isAllDone = !tools.some(tool => isToolPartActive(tool.content))
const hasActiveTools = tools.some(tool => isToolPartActive(tool.content))
const stepsSummary = descriptiveToolSteps ? buildDescriptiveToolStepsSummary(tools, t) : undefined
// 汇总所有成功完成的工具的 diff stats(失败的不算)
const totalDiffStats = useMemo(() => {
if (!descriptiveToolSteps) return undefined
let additions = 0,
deletions = 0
for (const tool of tools) {
if (currentToolFailed(tool.content)) continue
const data = extractToolData(toToolRenderPart(tool.content, messageContext.messageID))
const stats = data.diffStats || computePartDiffStats(data)
if (stats) {
additions += stats.additions
deletions += stats.deletions
}
}
return additions || deletions ? { additions, deletions } : undefined
}, [descriptiveToolSteps, tools, messageContext.messageID])
// 沉浸模式下:判断工具组是否包含需要用户阅读的工具
const hasReadableTools = immersiveMode && tools.some(tool => isReadableTool(tool.content.name))
// 过程折叠:steps 默认收起,只有权限/提问才自动展开
// 其它模式:活跃/流式/可读工具时展开
const shouldStartExpanded = processCollapseEnabled
? !!hasPendingInteraction
: !descriptiveToolSteps ||
hasActiveTools ||
hasPendingInteraction ||
(immersiveMode && !!isStreaming && hasReadableTools)
const groupStateKey = `message:${messageContext.messageID}:tool-group:${tools[0]?.id || 'empty'}`
const [expanded, setExpanded] = useUiDisclosureState(groupStateKey, shouldStartExpanded)
const hasAutoExpandedReadableRef = useRef(
!processCollapseEnabled && shouldStartExpanded && immersiveMode && hasReadableTools,
)
const {
rootRef: stepsRootRef,
headerRef: stepsHeaderRef,
withScrollLock: withStepsScrollLock,
} = useDisclosureScrollLock()
useEffect(() => {
if (!descriptiveToolSteps) return
// 权限/提问:必须展开让用户操作
if (hasPendingInteraction) {
setExpanded(true, { touched: false, respectUser: true })
return
}
// 过程折叠:不因 active/streaming 自动展开 steps
if (processCollapseEnabled) return
// 沉浸模式下没有可读工具:始终收起
if (immersiveMode && !hasReadableTools) {
setExpanded(false, { touched: false, respectUser: true })
return
}
if (hasActiveTools) {
if (immersiveMode && hasReadableTools) {
hasAutoExpandedReadableRef.current = true
}
setExpanded(true, { touched: false, respectUser: true })
return
}
// 某些可读工具(如 todo)可能首帧已完成,错过 running 态;流仍在继续时也自动展开一次
if (immersiveMode && isStreaming && hasReadableTools && !hasAutoExpandedReadableRef.current) {
hasAutoExpandedReadableRef.current = true
setExpanded(true, { touched: false, respectUser: true })
}
}, [
descriptiveToolSteps,
processCollapseEnabled,
hasActiveTools,
hasPendingInteraction,
immersiveMode,
hasReadableTools,
isStreaming,
setExpanded,
])
const effectiveExpanded = expanded || hasPendingInteraction
// Android expand: instant layout + max-height fake; collapse: original grid-rows.
// Only animate at the steps shell level so nested ToolPartView does not double-animate.
const {
contentRef: stepsExpandContentRef,
layoutOpen: stepsLayoutOpen,
keepMounted: stepsKeepMounted,
panelClassName: stepsPanelClassName,
} = useCompositorExpand(effectiveExpanded)
// 展开即挂工具行:默认展开时 header 与 body 同帧
const shouldRenderBody = useMessageExpandRender(stepsKeepMounted)
// compact: 单工具时用紧凑布局(图标内联,无 timeline 连接线)
// 不区分 streaming 状态 — 单工具始终 compact,第二个工具到来时再自然过渡到 timeline
const isSingleCompact = totalCount === 1 && !descriptiveToolSteps
// steps header: 多工具始终显示;描述型 steps 模式下,单工具也显示
const showStepsHeader = totalCount > 1 || descriptiveToolSteps
// 只 map 一次:有 header 时受 expand mount 控制,无 header 时始终挂载
const toolParts =
!showStepsHeader || shouldRenderBody
? tools.map((tool, idx) => (
<ToolPartView
key={tool.id}
part={toToolRenderPart(tool.content, messageContext.messageID)}
context={messageContext}
isFirst={idx === 0}
isLast={idx === tools.length - 1}
compact={isSingleCompact}
descriptive={descriptiveToolSteps}
isStreaming={isStreaming}
/>
))
: null
// 统一容器结构 — ToolPartView 始终在同一 React 树位置,
// streaming→idle / 1→N 工具切换时不 remount,expanded 状态不丢失
return (
<div ref={stepsRootRef} className="flex flex-col">
{showStepsHeader &&
(descriptiveToolSteps ? (
<button
type="button"
ref={stepsHeaderRef}
onClick={() => withStepsScrollLock(() => setExpanded(!expanded))}
className={`flex w-full items-baseline rounded-md ${MSG_SPACING.header} text-left hover:bg-bg-200/30 transition-colors`}
>
<span className="text-[length:var(--fs-sm)] leading-5">
{stepsSummary?.map((seg, i) => (
<span
key={i}
className={
seg.type === 'error'
? 'text-danger-100'
: seg.type === 'active'
? 'reasoning-shimmer-text'
: 'text-text-300'
}
>
{seg.text}
</span>
))}
</span>
{totalDiffStats && !hasActiveTools && (
<span className="ml-1.5 inline-flex items-center gap-1 text-[length:var(--fs-xxs)] font-mono font-medium tabular-nums">
{totalDiffStats.additions > 0 && <span className="text-success-100">+{totalDiffStats.additions}</span>}
{totalDiffStats.deletions > 0 && <span className="text-danger-100">-{totalDiffStats.deletions}</span>}
</span>
)}
</button>
) : (
<button
type="button"
ref={stepsHeaderRef}
onClick={() => withStepsScrollLock(() => setExpanded(!expanded))}
className={`flex items-center gap-1.5 ${MSG_SPACING.header} text-text-400 text-[length:var(--fs-base)] hover:text-text-200 hover:bg-bg-200/30 rounded-md transition-colors`}
>
<span className="inline-flex w-[14px] items-center justify-center shrink-0">
{effectiveExpanded ? <ChevronDownIcon size={14} /> : <ChevronRightIcon size={14} />}
</span>
<span className="inline-flex items-baseline gap-2 whitespace-nowrap">
<span className="text-[length:var(--fs-md)] font-medium leading-tight">
{isAllDone
? t('stepsCount', { done: totalCount, total: totalCount })
: t('stepsCount', { done: doneCount, total: totalCount })}
</span>