@@ -31,11 +31,12 @@ struct ChatDetailView: View {
3131 let latestRun = appState. runs ( for: currentAgent) . first
3232 let events = latestRun. map { appState. events ( for: $0. id) } ?? [ ]
3333 let timelineItems = ChatTimelineBuilder . items ( from: events)
34+ let timelineSections = ChatTimelineSection . sections ( from: timelineItems)
3435 let showsComposer = shouldShowComposer ( agent: currentAgent, run: latestRun)
3536
3637 ScrollViewReader { proxy in
3738 ScrollView {
38- LazyVStack ( alignment: . leading, spacing: 18 ) {
39+ LazyVStack ( alignment: . leading, spacing: 14 ) {
3940 CloudChatHeaderCard ( agent: currentAgent, run: latestRun)
4041
4142 if let latestRun {
@@ -44,9 +45,9 @@ struct ChatDetailView: View {
4445 isStreamExpired: appState. isStreamExpired ( runID: latestRun. id)
4546 )
4647 } else {
47- ForEach ( timelineItems ) { item in
48- ChatTimelineRow ( item : item )
49- . id ( item . id)
48+ ForEach ( timelineSections ) { section in
49+ ChatTimelineSectionView ( section : section )
50+ . id ( section . id)
5051 }
5152 }
5253
@@ -259,38 +260,7 @@ struct ChatDetailView: View {
259260
260261 Spacer ( minLength: 0 )
261262
262- if let run, !run. status. isTerminal {
263- Button {
264- Task {
265- await appState. cancel ( agent: agent, run: run)
266- }
267- } label: {
268- Image ( systemName: " stop.fill " )
269- . font ( . system( size: 15 , weight: . semibold) )
270- . foregroundStyle ( . red)
271- . frame ( width: Self . composerControlSize, height: Self . composerControlSize)
272- . composerGlassSurface ( cornerRadius: Self . composerControlSize / 2 , interactive: true )
273- }
274- . buttonStyle ( . plain)
275- . accessibilityLabel ( " Cancel run " )
276- }
277-
278- Button {
279- sendFollowUp ( agent: agent)
280- } label: {
281- Image ( systemName: " arrow.up " )
282- . font ( . system( size: 16 , weight: . bold) )
283- . foregroundStyle ( canSendFollowUp ? Color . white : Color ( uiColor: . systemGray) )
284- . frame ( width: 40 , height: 40 )
285- . background (
286- Circle ( )
287- . fill ( canSendFollowUp ? Color ( uiColor: . systemBlue) : Color ( uiColor: . systemGray5) )
288- )
289- . shadow ( color: canSendFollowUp ? Color . blue. opacity ( 0.28 ) : . clear, radius: 10 , y: 5 )
290- }
291- . buttonStyle ( . plain)
292- . disabled ( !canSendFollowUp)
293- . accessibilityLabel ( " Send follow-up " )
263+ primaryComposerActionButton ( agent: agent, run: run)
294264 }
295265 . padding ( . horizontal, 10 )
296266 . padding ( . vertical, 8 )
@@ -339,6 +309,43 @@ struct ChatDetailView: View {
339309 }
340310 }
341311
312+ @ViewBuilder
313+ private func primaryComposerActionButton( agent: Agent , run: AgentRun ? ) -> some View {
314+ if let run, !run. status. isTerminal {
315+ Button {
316+ Task {
317+ await appState. cancel ( agent: agent, run: run)
318+ }
319+ } label: {
320+ Image ( systemName: " stop.fill " )
321+ . font ( . system( size: 15 , weight: . bold) )
322+ . foregroundStyle ( . white)
323+ . frame ( width: 40 , height: 40 )
324+ . background ( Circle ( ) . fill ( Color ( uiColor: . systemRed) ) )
325+ . shadow ( color: Color . red. opacity ( 0.26 ) , radius: 10 , y: 5 )
326+ }
327+ . buttonStyle ( . plain)
328+ . accessibilityLabel ( " Cancel run " )
329+ } else {
330+ Button {
331+ sendFollowUp ( agent: agent)
332+ } label: {
333+ Image ( systemName: " arrow.up " )
334+ . font ( . system( size: 16 , weight: . bold) )
335+ . foregroundStyle ( canSendFollowUp ? Color . white : Color ( uiColor: . systemGray) )
336+ . frame ( width: 40 , height: 40 )
337+ . background (
338+ Circle ( )
339+ . fill ( canSendFollowUp ? Color ( uiColor: . systemBlue) : Color ( uiColor: . systemGray5) )
340+ )
341+ . shadow ( color: canSendFollowUp ? Color . blue. opacity ( 0.28 ) : . clear, radius: 10 , y: 5 )
342+ }
343+ . buttonStyle ( . plain)
344+ . disabled ( !canSendFollowUp)
345+ . accessibilityLabel ( " Send follow-up " )
346+ }
347+ }
348+
342349 private var shouldShowFollowUpAttachments : Bool {
343350 !followUpImages. isEmpty || !followUpFiles. isEmpty || isLoadingFollowUpImages || isLoadingFollowUpFiles
344351 }
@@ -604,14 +611,67 @@ private struct CloudChatEmptyTimeline: View {
604611 }
605612}
606613
614+ private struct ChatTimelineSection : Identifiable , Hashable {
615+ var id : String
616+ var items : [ ChatTimelineItem ]
617+ var isActivityLog : Bool
618+
619+ static func sections( from items: [ ChatTimelineItem ] ) -> [ ChatTimelineSection ] {
620+ var sections : [ ChatTimelineSection ] = [ ]
621+ var activityItems : [ ChatTimelineItem ] = [ ]
622+
623+ func flushActivityItems( ) {
624+ guard !activityItems. isEmpty else { return }
625+ sections. append (
626+ ChatTimelineSection (
627+ id: activityItems. map ( \. id) . joined ( separator: " - " ) ,
628+ items: activityItems,
629+ isActivityLog: true
630+ )
631+ )
632+ activityItems = [ ]
633+ }
634+
635+ for item in items {
636+ if item. isActivityLogItem {
637+ activityItems. append ( item)
638+ } else {
639+ flushActivityItems ( )
640+ sections. append (
641+ ChatTimelineSection (
642+ id: item. id,
643+ items: [ item] ,
644+ isActivityLog: false
645+ )
646+ )
647+ }
648+ }
649+
650+ flushActivityItems ( )
651+ return sections
652+ }
653+ }
654+
655+ private struct ChatTimelineSectionView : View {
656+ var section : ChatTimelineSection
657+
658+ var body : some View {
659+ if section. isActivityLog {
660+ CloudActivityLog ( items: section. items)
661+ } else if let item = section. items. first {
662+ ChatTimelineRow ( item: item)
663+ }
664+ }
665+ }
666+
607667private struct ChatTimelineRow : View {
608668 var item : ChatTimelineItem
609669
610670 var body : some View {
611671 switch item. kind {
612672 case . user:
613673 UserMessageRow ( item: item)
614- case . assistant:
674+ case . assistant, . result :
615675 AssistantMessageRow ( item: item)
616676 case . status, . done, . heartbeat:
617677 CloudStatusEventPill ( item: item)
@@ -621,6 +681,198 @@ private struct ChatTimelineRow: View {
621681 }
622682}
623683
684+ private struct CloudActivityLog : View {
685+ var items : [ ChatTimelineItem ]
686+
687+ var body : some View {
688+ VStack ( spacing: 0 ) {
689+ ForEach ( items. indices, id: \. self) { index in
690+ CloudActivityCompactRow ( item: items [ index] )
691+
692+ if index < items. index ( before: items. endIndex) {
693+ Divider ( )
694+ . opacity ( 0.35 )
695+ . padding ( . leading, 46 )
696+ }
697+ }
698+ }
699+ . background (
700+ RoundedRectangle ( cornerRadius: 18 , style: . continuous)
701+ . fill ( Color ( uiColor: . secondarySystemBackground) . opacity ( 0.58 ) )
702+ )
703+ . overlay (
704+ RoundedRectangle ( cornerRadius: 18 , style: . continuous)
705+ . stroke ( Color ( uiColor: . separator) . opacity ( 0.16 ) , lineWidth: 0.5 )
706+ )
707+ }
708+ }
709+
710+ private struct CloudActivityCompactRow : View {
711+ var item : ChatTimelineItem
712+ @State private var isExpanded : Bool
713+
714+ init ( item: ChatTimelineItem ) {
715+ self . item = item
716+ _isExpanded = State ( initialValue: item. kind == . error || item. kind == . request)
717+ }
718+
719+ var body : some View {
720+ Group {
721+ if hasUsefulDetail {
722+ DisclosureGroup ( isExpanded: $isExpanded) {
723+ TimelineMessageText (
724+ message: item. message,
725+ isTechnical: isTechnical,
726+ rendersMarkdown: rendersMarkdown,
727+ foregroundColor: messageColor
728+ )
729+ . padding ( . leading, 34 )
730+ . padding ( . trailing, 8 )
731+ . padding ( . top, 6 )
732+ . padding ( . bottom, 10 )
733+ } label: {
734+ label
735+ }
736+ . tint ( . secondary)
737+ } else {
738+ label
739+ }
740+ }
741+ . padding ( . horizontal, 12 )
742+ . padding ( . vertical, hasUsefulDetail && isExpanded ? 10 : 9 )
743+ . accessibilityElement ( children: . combine)
744+ }
745+
746+ private var label : some View {
747+ HStack ( alignment: . center, spacing: 10 ) {
748+ Image ( systemName: symbolName)
749+ . font ( . caption. weight ( . semibold) )
750+ . foregroundStyle ( color)
751+ . frame ( width: 22 )
752+
753+ VStack ( alignment: . leading, spacing: 2 ) {
754+ Text ( compactTitle)
755+ . font ( . subheadline. weight ( . semibold) )
756+ . foregroundStyle ( . primary)
757+ . lineLimit ( 1 )
758+
759+ if !isExpanded, hasUsefulDetail, shouldShowCollapsedPreview {
760+ Text ( collapsedPreview)
761+ . font ( . caption)
762+ . foregroundStyle ( . secondary)
763+ . lineLimit ( 1 )
764+ }
765+ }
766+
767+ Spacer ( minLength: 8 )
768+
769+ Text ( item. timestamp)
770+ . font ( . caption2)
771+ . foregroundStyle ( . tertiary)
772+ }
773+ }
774+
775+ private var compactTitle : String {
776+ switch item. kind {
777+ case . toolCall:
778+ item. title. localizedCaseInsensitiveContains ( " Started " ) ? " Tool Call Started " : " Tool Call "
779+ case . thinking:
780+ item. title. localizedCaseInsensitiveContains ( " Completed " ) ? " Thinking Completed " : " Thinking "
781+ case . task:
782+ item. title
783+ case . done:
784+ " Turn Ended "
785+ default :
786+ item. title
787+ }
788+ }
789+
790+ private var hasUsefulDetail : Bool {
791+ let message = item. message. trimmingCharacters ( in: . whitespacesAndNewlines)
792+ guard !message. isEmpty else { return false }
793+ return message. localizedCaseInsensitiveCompare ( item. title) != . orderedSame
794+ && message. localizedCaseInsensitiveCompare ( compactTitle) != . orderedSame
795+ && message != " Stream closed "
796+ }
797+
798+ private var shouldShowCollapsedPreview : Bool {
799+ switch item. kind {
800+ case . thinking, . toolCall, . task, . request, . error:
801+ true
802+ default :
803+ false
804+ }
805+ }
806+
807+ private var collapsedPreview : String {
808+ item. message
809+ . replacingOccurrences ( of: " \n " , with: " " )
810+ . trimmingCharacters ( in: . whitespacesAndNewlines)
811+ }
812+
813+ private var isTechnical : Bool {
814+ item. kind == . toolCall || item. kind == . error
815+ }
816+
817+ private var rendersMarkdown : Bool {
818+ switch item. kind {
819+ case . thinking, . task, . request:
820+ true
821+ default :
822+ false
823+ }
824+ }
825+
826+ private var messageColor : Color {
827+ switch item. kind {
828+ case . error:
829+ . red
830+ default :
831+ . secondary
832+ }
833+ }
834+
835+ private var symbolName : String {
836+ switch item. kind {
837+ case . system:
838+ " gearshape "
839+ case . status:
840+ " checkmark.circle "
841+ case . thinking:
842+ " brain "
843+ case . toolCall:
844+ " terminal "
845+ case . task:
846+ " checklist "
847+ case . request:
848+ " questionmark.bubble "
849+ case . heartbeat:
850+ " waveform.path.ecg "
851+ case . error:
852+ " exclamationmark.triangle "
853+ case . done:
854+ " checkmark.seal "
855+ default :
856+ " circle "
857+ }
858+ }
859+
860+ private var color : Color {
861+ switch item. kind {
862+ case . status, . done:
863+ . green
864+ case . error:
865+ . red
866+ case . thinking, . request:
867+ . orange
868+ case . toolCall, . task:
869+ . blue
870+ default :
871+ . secondary
872+ }
873+ }
874+ }
875+
624876private struct UserMessageRow : View {
625877 var item : ChatTimelineItem
626878
@@ -796,6 +1048,17 @@ private struct CloudActivityDisclosureRow: View {
7961048 }
7971049}
7981050
1051+ private extension ChatTimelineItem {
1052+ var isActivityLogItem : Bool {
1053+ switch kind {
1054+ case . system, . status, . thinking, . toolCall, . task, . request, . heartbeat, . error, . done, . unknown:
1055+ true
1056+ case . user, . assistant, . result:
1057+ false
1058+ }
1059+ }
1060+ }
1061+
7991062private struct CloudStatusEventPill : View {
8001063 var item : ChatTimelineItem
8011064
0 commit comments