Skip to content

Commit ba77997

Browse files
committed
ios: render streaming user placeholders as skeleton
1 parent 94219a2 commit ba77997

3 files changed

Lines changed: 114 additions & 12 deletions

File tree

mobile/garyx-mobile/App/GaryxMobile/GaryxMobileConversationViews.swift

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1924,6 +1924,8 @@ struct GaryxMessageBubble: View {
19241924
if let notification = taskNotification {
19251925
GaryxTaskNotificationCard(notification: notification)
19261926
.garyxMessageInteraction(text: taskNotificationCopyText(notification), edge: .trailing)
1927+
} else if messagePresentation == .historySkeleton {
1928+
GaryxUserMessageLoadingBubble()
19271929
} else if !displayText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
19281930
GaryxMarkdownText(
19291931
text: displayText,
@@ -1956,8 +1958,8 @@ struct GaryxMessageBubble: View {
19561958
.garyxMessageCopyContext(text: messageCopyText)
19571959
}
19581960
if message.isStreaming && message.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
1959-
if message.attachments.isEmpty {
1960-
GaryxThinkingLabel()
1961+
if case .thinkingLabel(let text) = messagePresentation {
1962+
GaryxThinkingLabel(text: text)
19611963
}
19621964
} else if let notification = taskNotification {
19631965
GaryxTaskNotificationCard(notification: notification)
@@ -2005,16 +2007,12 @@ struct GaryxMessageBubble: View {
20052007
}
20062008
}
20072009

2010+
private var messagePresentation: GaryxMobileMessagePresentation {
2011+
GaryxMobileMessagePresentation.make(for: message)
2012+
}
2013+
20082014
private var displayText: String {
2009-
if message.text.isEmpty, message.isStreaming { return "Thinking" }
2010-
if !message.attachments.isEmpty,
2011-
let summary = GaryxStructuredContentRenderer.attachmentSummary(
2012-
from: message.attachments.map(\.contentDescriptor)
2013-
),
2014-
message.text == summary {
2015-
return ""
2016-
}
2017-
return message.text
2015+
messagePresentation.text
20182016
}
20192017

20202018
private var taskNotification: GaryxTaskNotification? {
@@ -2522,12 +2520,41 @@ struct GaryxMessageFileAttachmentView: View {
25222520
}
25232521

25242522
struct GaryxThinkingLabel: View {
2523+
var text: String = "Thinking"
2524+
25252525
var body: some View {
2526-
GaryxShimmerText(text: "Thinking", font: GaryxFont.body())
2526+
GaryxShimmerText(text: text, font: GaryxFont.body())
25272527
.frame(minHeight: 22)
25282528
}
25292529
}
25302530

2531+
struct GaryxUserMessageLoadingBubble: View {
2532+
private static let shimmerDuration: Double = 2.4
2533+
2534+
var body: some View {
2535+
TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: false)) { context in
2536+
let normalized = context.date.timeIntervalSinceReferenceDate
2537+
.truncatingRemainder(dividingBy: Self.shimmerDuration) / Self.shimmerDuration
2538+
let phase = CGFloat(normalized) * 2.0 - 0.5
2539+
let fill = LinearGradient(
2540+
colors: [
2541+
Color.primary.opacity(0.05),
2542+
Color.primary.opacity(0.11),
2543+
Color.primary.opacity(0.05),
2544+
],
2545+
startPoint: UnitPoint(x: phase - 0.6, y: 0.35),
2546+
endPoint: UnitPoint(x: phase + 0.6, y: 0.65)
2547+
)
2548+
2549+
RoundedRectangle(cornerRadius: 19, style: .continuous)
2550+
.fill(fill)
2551+
.frame(width: 156, height: 38)
2552+
}
2553+
.accessibilityElement(children: .ignore)
2554+
.accessibilityLabel("Loading message")
2555+
}
2556+
}
2557+
25312558
/// Tail banner shown when the selected thread's last run was cut off by the
25322559
/// provider's usage quota. The countdown re-derives every second from the
25332560
/// server-provided reset time via `GaryxRateLimitBannerModel`; when the gateway

mobile/garyx-mobile/Sources/GaryxMobileCore/GaryxMobileTranscriptModel.swift

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,47 @@ struct GaryxMobileMessageAttachment: Identifiable, Equatable {
5656
}
5757
}
5858

59+
enum GaryxMobileMessagePresentation: Equatable {
60+
case text(String)
61+
case thinkingLabel(text: String)
62+
case historySkeleton
63+
64+
var text: String {
65+
switch self {
66+
case .text(let text), .thinkingLabel(let text):
67+
text
68+
case .historySkeleton:
69+
""
70+
}
71+
}
72+
73+
static func make(for message: GaryxMobileMessage) -> GaryxMobileMessagePresentation {
74+
let trimmedText = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
75+
76+
if message.isStreaming, trimmedText.isEmpty {
77+
guard message.attachments.isEmpty else { return .text("") }
78+
switch message.role {
79+
case .user:
80+
return .historySkeleton
81+
case .assistant:
82+
return .thinkingLabel(text: "Thinking")
83+
case .system, .tool:
84+
return .text("")
85+
}
86+
}
87+
88+
if !message.attachments.isEmpty,
89+
let summary = GaryxStructuredContentRenderer.attachmentSummary(
90+
from: message.attachments.map(\.contentDescriptor)
91+
),
92+
message.text == summary {
93+
return .text("")
94+
}
95+
96+
return .text(message.text)
97+
}
98+
}
99+
59100
enum GaryxMobileTranscriptMapper {}
60101

61102
enum GaryxMobileToolTraceStatus: String, Equatable {

mobile/garyx-mobile/Tests/GaryxMobileCoreTests/GaryxMobilePresentationModelsTests.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,40 @@ import XCTest
22
@testable import GaryxMobileCore
33

44
final class GaryxMobilePresentationModelsTests: XCTestCase {
5+
func testBodylessStreamingUserPlaceholderPresentsAsHistorySkeleton() {
6+
let message = GaryxMobileMessage(
7+
id: "history:98",
8+
role: .user,
9+
text: "",
10+
timestamp: nil,
11+
isStreaming: true,
12+
localState: .remotePartial,
13+
historyIndex: 98
14+
)
15+
16+
let presentation = GaryxMobileMessagePresentation.make(for: message)
17+
18+
XCTAssertEqual(presentation, .historySkeleton)
19+
XCTAssertNotEqual(presentation.text, "Thinking")
20+
}
21+
22+
func testBodylessStreamingAssistantStillPresentsAsThinkingLabel() {
23+
let message = GaryxMobileMessage(
24+
id: "history:99",
25+
role: .assistant,
26+
text: "",
27+
timestamp: nil,
28+
isStreaming: true,
29+
localState: .remotePartial,
30+
historyIndex: 99
31+
)
32+
33+
let presentation = GaryxMobileMessagePresentation.make(for: message)
34+
35+
XCTAssertEqual(presentation, .thinkingLabel(text: "Thinking"))
36+
XCTAssertEqual(presentation.text, "Thinking")
37+
}
38+
539
func testRunStateResolverPreservesAPIRunStateWithoutCommittedOverride() {
640
XCTAssertEqual(
741
GaryxThreadSummaryRunStateResolver.resolvedRunState(

0 commit comments

Comments
 (0)