@@ -10,7 +10,20 @@ import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll";
1010import { type TurnDiffSummary } from "../../types" ;
1111import { summarizeTurnDiffStats } from "../../lib/turnDiffTree" ;
1212import ChatMarkdown from "../ChatMarkdown" ;
13- import { Undo2Icon } from "lucide-react" ;
13+ import {
14+ BotIcon ,
15+ CheckIcon ,
16+ CircleAlertIcon ,
17+ EyeIcon ,
18+ GlobeIcon ,
19+ HammerIcon ,
20+ type LucideIcon ,
21+ SquarePenIcon ,
22+ TerminalIcon ,
23+ Undo2Icon ,
24+ WrenchIcon ,
25+ ZapIcon ,
26+ } from "lucide-react" ;
1427import { Button } from "../ui/button" ;
1528import { clamp } from "effect/Number" ;
1629import { estimateTimelineMessageHeight } from "../timelineHeight" ;
@@ -19,7 +32,8 @@ import { ProposedPlanCard } from "./ProposedPlanCard";
1932import { ChangedFilesTree } from "./ChangedFilesTree" ;
2033import { DiffStatLabel , hasNonZeroStat } from "./DiffStatLabel" ;
2134import { MessageCopyButton } from "./MessageCopyButton" ;
22- import { computeMessageDurationStart } from "./MessagesTimeline.logic" ;
35+ import { computeMessageDurationStart , normalizeCompactToolLabel } from "./MessagesTimeline.logic" ;
36+ import { cn } from "~/lib/utils" ;
2337import { type TimestampFormat } from "../../appSettings" ;
2438import { formatTimestamp } from "../../timestampFormat" ;
2539
@@ -289,72 +303,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({
289303 : groupedEntries ;
290304 const hiddenCount = groupedEntries . length - visibleEntries . length ;
291305 const onlyToolEntries = groupedEntries . every ( ( entry ) => entry . tone === "tool" ) ;
292- const groupLabel = onlyToolEntries
293- ? groupedEntries . length === 1
294- ? "Tool call"
295- : `Tool calls (${ groupedEntries . length } )`
296- : groupedEntries . length === 1
297- ? "Work event"
298- : `Work log (${ groupedEntries . length } )` ;
306+ const showHeader = hasOverflow || ! onlyToolEntries ;
307+ const groupLabel = onlyToolEntries ? "Tool calls" : "Work log" ;
299308
300309 return (
301- < div className = "rounded-lg border border-border/80 bg-card/45 px-3 py-2" >
302- < div className = "mb-1.5 flex items-center justify-between gap-3" >
303- < p className = "text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65" >
304- { groupLabel }
305- </ p >
306- { hasOverflow && (
307- < button
308- type = "button"
309- className = "text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80"
310- onClick = { ( ) => onToggleWorkGroup ( groupId ) }
311- >
312- { isExpanded ? "Show less" : `Show ${ hiddenCount } more` }
313- </ button >
314- ) }
315- </ div >
316- < div className = "space-y-1" >
310+ < div className = "rounded-xl border border-border/45 bg-card/25 px-2 py-1.5" >
311+ { showHeader && (
312+ < div className = "mb-1.5 flex items-center justify-between gap-2 px-0.5" >
313+ < p className = "text-[9px] uppercase tracking-[0.16em] text-muted-foreground/55" >
314+ { groupLabel } ({ groupedEntries . length } )
315+ </ p >
316+ { hasOverflow && (
317+ < button
318+ type = "button"
319+ className = "text-[9px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-foreground/75"
320+ onClick = { ( ) => onToggleWorkGroup ( groupId ) }
321+ >
322+ { isExpanded ? "Show less" : `Show ${ hiddenCount } more` }
323+ </ button >
324+ ) }
325+ </ div >
326+ ) }
327+ < div className = "space-y-0.5" >
317328 { visibleEntries . map ( ( workEntry ) => (
318- < div key = { `work-row:${ workEntry . id } ` } className = "flex items-start gap-2 py-0.5" >
319- < span className = "mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
320- < div className = "min-w-0 flex-1 py-[2px]" >
321- < p className = { `text-[11px] leading-relaxed ${ workToneClass ( workEntry . tone ) } ` } >
322- { workEntry . label }
323- </ p >
324- { workEntry . command && (
325- < pre className = "mt-1 overflow-x-auto rounded-md border border-border/70 bg-background/80 px-2 py-1 font-mono text-[11px] leading-relaxed text-foreground/80" >
326- { workEntry . command }
327- </ pre >
328- ) }
329- { workEntry . changedFiles && workEntry . changedFiles . length > 0 && (
330- < div className = "mt-1 flex flex-wrap gap-1" >
331- { workEntry . changedFiles . slice ( 0 , 6 ) . map ( ( filePath ) => (
332- < span
333- key = { `${ workEntry . id } :${ filePath } ` }
334- className = "rounded-md border border-border/70 bg-background/65 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground/85"
335- title = { filePath }
336- >
337- { filePath }
338- </ span >
339- ) ) }
340- { workEntry . changedFiles . length > 6 && (
341- < span className = "px-1 text-[10px] text-muted-foreground/65" >
342- +{ workEntry . changedFiles . length - 6 } more
343- </ span >
344- ) }
345- </ div >
346- ) }
347- { workEntry . detail &&
348- ( ! workEntry . command || workEntry . detail !== workEntry . command ) && (
349- < p
350- className = "mt-1 text-[11px] leading-relaxed text-muted-foreground/75"
351- title = { workEntry . detail }
352- >
353- { workEntry . detail }
354- </ p >
355- ) }
356- </ div >
357- </ div >
329+ < SimpleWorkEntryRow key = { `work-row:${ workEntry . id } ` } workEntry = { workEntry } />
358330 ) ) }
359331 </ div >
360332 </ div >
@@ -664,9 +636,148 @@ function formatMessageMeta(
664636 return `${ formatTimestamp ( createdAt , timestampFormat ) } • ${ duration } ` ;
665637}
666638
639+ function workToneIcon ( tone : TimelineWorkEntry [ "tone" ] ) : {
640+ icon : LucideIcon ;
641+ className : string ;
642+ } {
643+ if ( tone === "error" ) {
644+ return {
645+ icon : CircleAlertIcon ,
646+ className : "text-foreground/92" ,
647+ } ;
648+ }
649+ if ( tone === "thinking" ) {
650+ return {
651+ icon : BotIcon ,
652+ className : "text-foreground/92" ,
653+ } ;
654+ }
655+ if ( tone === "info" ) {
656+ return {
657+ icon : CheckIcon ,
658+ className : "text-foreground/92" ,
659+ } ;
660+ }
661+ return {
662+ icon : ZapIcon ,
663+ className : "text-foreground/92" ,
664+ } ;
665+ }
666+
667667function workToneClass ( tone : "thinking" | "tool" | "info" | "error" ) : string {
668668 if ( tone === "error" ) return "text-rose-300/50 dark:text-rose-300/50" ;
669669 if ( tone === "tool" ) return "text-muted-foreground/70" ;
670670 if ( tone === "thinking" ) return "text-muted-foreground/50" ;
671671 return "text-muted-foreground/40" ;
672672}
673+
674+ function workEntryPreview (
675+ workEntry : Pick < TimelineWorkEntry , "detail" | "command" | "changedFiles" > ,
676+ ) {
677+ if ( workEntry . command ) return workEntry . command ;
678+ if ( workEntry . detail ) return workEntry . detail ;
679+ if ( ( workEntry . changedFiles ?. length ?? 0 ) === 0 ) return null ;
680+ const [ firstPath ] = workEntry . changedFiles ?? [ ] ;
681+ if ( ! firstPath ) return null ;
682+ return workEntry . changedFiles ! . length === 1
683+ ? firstPath
684+ : `${ firstPath } +${ workEntry . changedFiles ! . length - 1 } more` ;
685+ }
686+
687+ function workEntryIcon ( workEntry : TimelineWorkEntry ) : LucideIcon {
688+ if ( workEntry . requestKind === "command" ) return TerminalIcon ;
689+ if ( workEntry . requestKind === "file-read" ) return EyeIcon ;
690+ if ( workEntry . requestKind === "file-change" ) return SquarePenIcon ;
691+
692+ if ( workEntry . itemType === "command_execution" || workEntry . command ) {
693+ return TerminalIcon ;
694+ }
695+ if ( workEntry . itemType === "file_change" || ( workEntry . changedFiles ?. length ?? 0 ) > 0 ) {
696+ return SquarePenIcon ;
697+ }
698+ if ( workEntry . itemType === "web_search" ) return GlobeIcon ;
699+ if ( workEntry . itemType === "image_view" ) return EyeIcon ;
700+
701+ switch ( workEntry . itemType ) {
702+ case "mcp_tool_call" :
703+ return WrenchIcon ;
704+ case "dynamic_tool_call" :
705+ case "collab_agent_tool_call" :
706+ return HammerIcon ;
707+ }
708+
709+ return workToneIcon ( workEntry . tone ) . icon ;
710+ }
711+
712+ function capitalizePhrase ( value : string ) : string {
713+ const trimmed = value . trim ( ) ;
714+ if ( trimmed . length === 0 ) {
715+ return value ;
716+ }
717+ return `${ trimmed . charAt ( 0 ) . toUpperCase ( ) } ${ trimmed . slice ( 1 ) } ` ;
718+ }
719+
720+ function toolWorkEntryHeading ( workEntry : TimelineWorkEntry ) : string {
721+ if ( ! workEntry . toolTitle ) {
722+ return capitalizePhrase ( normalizeCompactToolLabel ( workEntry . label ) ) ;
723+ }
724+ return capitalizePhrase ( normalizeCompactToolLabel ( workEntry . toolTitle ) ) ;
725+ }
726+
727+ const SimpleWorkEntryRow = memo ( function SimpleWorkEntryRow ( props : {
728+ workEntry : TimelineWorkEntry ;
729+ } ) {
730+ const { workEntry } = props ;
731+ const iconConfig = workToneIcon ( workEntry . tone ) ;
732+ const EntryIcon = workEntryIcon ( workEntry ) ;
733+ const heading = toolWorkEntryHeading ( workEntry ) ;
734+ const preview = workEntryPreview ( workEntry ) ;
735+ const displayText = preview ? `${ heading } - ${ preview } ` : heading ;
736+ const hasChangedFiles = ( workEntry . changedFiles ?. length ?? 0 ) > 0 ;
737+ const previewIsChangedFiles = hasChangedFiles && ! workEntry . command && ! workEntry . detail ;
738+
739+ return (
740+ < div className = "rounded-lg px-1 py-1" >
741+ < div className = "flex items-center gap-2 transition-[opacity,translate] duration-200" >
742+ < span
743+ className = { cn ( "flex size-5 shrink-0 items-center justify-center" , iconConfig . className ) }
744+ >
745+ < EntryIcon className = "size-3" />
746+ </ span >
747+ < div className = "min-w-0 flex-1 overflow-hidden" >
748+ < p
749+ className = { cn (
750+ "truncate text-[11px] leading-5" ,
751+ workToneClass ( workEntry . tone ) ,
752+ preview ? "text-muted-foreground/70" : "" ,
753+ ) }
754+ title = { displayText }
755+ >
756+ < span className = { cn ( "text-foreground/80" , workToneClass ( workEntry . tone ) ) } >
757+ { heading }
758+ </ span >
759+ { preview && < span className = "text-muted-foreground/55" > - { preview } </ span > }
760+ </ p >
761+ </ div >
762+ </ div >
763+ { hasChangedFiles && ! previewIsChangedFiles && (
764+ < div className = "mt-1 flex flex-wrap gap-1 pl-6" >
765+ { workEntry . changedFiles ?. slice ( 0 , 4 ) . map ( ( filePath ) => (
766+ < span
767+ key = { `${ workEntry . id } :${ filePath } ` }
768+ className = "rounded-md border border-border/55 bg-background/75 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground/75"
769+ title = { filePath }
770+ >
771+ { filePath }
772+ </ span >
773+ ) ) }
774+ { ( workEntry . changedFiles ?. length ?? 0 ) > 4 && (
775+ < span className = "px-1 text-[10px] text-muted-foreground/55" >
776+ +{ ( workEntry . changedFiles ?. length ?? 0 ) - 4 }
777+ </ span >
778+ ) }
779+ </ div >
780+ ) }
781+ </ div >
782+ ) ;
783+ } ) ;
0 commit comments