diff --git a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift index 4fb2551..548b2c8 100644 --- a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift +++ b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift @@ -111,15 +111,7 @@ struct MessageBubble: View { var body: some View { if role == .user { - HStack { - Spacer(minLength: 48) - MarkdownText(markdown: text) - .textSelection(.enabled) - .padding(.horizontal, 14) - .padding(.vertical, 9) - .background(Palette.accent.opacity(0.15), - in: RoundedRectangle(cornerRadius: 18, style: .continuous)) - } + UserMessageBubble(text: text) } else { MarkdownText(markdown: text) .textSelection(.enabled) @@ -136,6 +128,93 @@ struct MessageBubble: View { } } +/// The user's own message: a right-aligned accent bubble that collapses a long message to a +/// capped height with a "Show more"/"Show less" toggle — the phone echo of the Mac's +/// `UserMessageBubble`. The Mac clamps a plain `Text` by line count; here the bubble renders +/// through `MarkdownText` (a stack of per-line/-block `Text`s that `.lineLimit` can't reach), so +/// we clamp by height instead. A hidden full-height copy of the bubble — laid out at the visible +/// bubble's width but with no height limit — measures the natural content height, so the toggle +/// appears only when collapsing actually hides something. The last visible line fades when +/// collapsed so the cut reads as "more below" rather than a glyph sliced in half. +private struct UserMessageBubble: View { + let text: String + @State private var expanded = false + @State private var fullHeight: CGFloat = 0 + /// Collapsed bubbles show at most this much prose (~12 wrapped lines at the base size, matching + /// the Mac's 12-line clamp) before a "Show more". A `@ScaledMetric` so it grows with the user's + /// text-size setting alongside `MarkdownText`'s own scaled prose size. + @ScaledMetric(relativeTo: .body) private var collapsedMaxHeight: CGFloat = 288 + /// Collapsing only hides something — and the toggle is only meaningful — when the full, + /// unconstrained content is taller than the collapsed cap. The +1 absorbs sub-point rounding. + private var isTruncated: Bool { fullHeight > collapsedMaxHeight + 1 } + private var collapsed: Bool { isTruncated && !expanded } + + var body: some View { + VStack(alignment: .trailing, spacing: 4) { + HStack { + Spacer(minLength: 48) + MarkdownText(markdown: text) + .textSelection(.enabled) + .padding(.horizontal, 14) + .padding(.vertical, 9) + // Cap the height whenever collapsed; harmless for a short message (the cap sits + // above its natural height, so nothing clips). `.clipped` cuts the overflow. + .frame(maxHeight: expanded ? nil : collapsedMaxHeight, alignment: .top) + .clipped() + .mask { + if collapsed { collapseFade } else { Color.black } + } + // The bubble fill is added after the mask, so only the prose fades — not the + // accent behind it, which stays a solid rounded rect. + .background(alignment: .top) { heightProbe } + .background(Palette.accent.opacity(0.15), + in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + if isTruncated { + Button { expanded.toggle() } label: { + Text(expanded ? "Show less" : "Show more") + .font(.caption.weight(.medium)) + .foregroundStyle(Palette.accent) + } + .buttonStyle(.plain) + .padding(.trailing, 4) + } + } + } + + /// A hidden copy of the bubble laid out at the visible bubble's width but with no height limit, + /// so its measured height is the full content height regardless of the collapse cap. Attached as + /// a background so it inherits the visible bubble's width and wraps identically. + private var heightProbe: some View { + MarkdownText(markdown: text) + .padding(.horizontal, 14) + .padding(.vertical, 9) + .fixedSize(horizontal: false, vertical: true) + .background(GeometryReader { geo in + Color.clear.preference(key: FullHeightKey.self, value: geo.size.height) + }) + .hidden() + .onPreferenceChange(FullHeightKey.self) { fullHeight = $0 } + } + + /// Solid for all but the bottom sliver, then fades to clear — so a collapsed bubble's last + /// visible line dissolves instead of being clipped mid-glyph. + private var collapseFade: some View { + LinearGradient( + stops: [ + .init(color: .black, location: 0), + .init(color: .black, location: 0.86), + .init(color: .clear, location: 1), + ], + startPoint: .top, endPoint: .bottom) + } +} + +private struct FullHeightKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) } +} + /// The agent's reasoning, set apart from its prose by a quiet left rule and a `sparkles` glyph, /// italic and muted — the phone echo of the Mac's `thinkingRow`. Renders nothing for an /// empty/redacted thinking block (matching the desktop), so a placeholder row never shows.