Skip to content

Commit 7f318b6

Browse files
committed
Refine cloud chat timeline UI
1 parent 789938d commit 7f318b6

2 files changed

Lines changed: 339 additions & 42 deletions

File tree

Runline/Features/ChatDetailView.swift

Lines changed: 300 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
607667
private 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+
624876
private 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+
7991062
private struct CloudStatusEventPill: View {
8001063
var item: ChatTimelineItem
8011064

0 commit comments

Comments
 (0)