M8: chat content density (code blocks, scroll anchor, context menus)

Bundles M8 items 2.4, 2.5, 2.6, 2.7 because they all touch ChatView
and together make the conversation readable on a phone:

2.4 — fenced code blocks (```…```) now render in a horizontally-
scrollable monospaced block inside the bubble. Collapsed to 240pt
max height with Expand/Collapse + a copy button; long shell
one-liners / JSON / stack traces stay one line each instead of
soft-wrapping into unreadable 4-line columns. New
`ChatContentFormatter.segments(for:)` splits the message body into
alternating `.text` (routed through AttributedString markdown) and
`.code` (routed to the new CodeBlockView). Deliberately simple
parser — handles the common fence shape, leaves inline backticks
to AttributedString, and falls back to plain text on unterminated
fences so nothing is ever silently swallowed.

2.5 — tool-call cards were already collapsed-by-default via a chevron
toggle. No structural change needed for M8; leaving the existing
ToolCallCard in place.

2.6 — replace the manual `onChange → proxy.scrollTo("bottom")`
pattern with iOS 17+ `.defaultScrollAnchor(.bottom)` plus iOS 18's
`.defaultScrollAnchor(.bottom, for: .sizeChanges)`. Native scroll-
pin fights the user's own scroll-up gesture less (the manual pattern
yanked you back to the bottom if a chunk arrived mid-read).
"New messages" pill for upward scroll-break deferred — needs a bit
of ScrollPosition state we don't plumb yet.

2.7 — `.contextMenu` on every message bubble with Copy + Share
(via ShareLink). User + assistant bubbles both. Code blocks get
their own copy button in the header. Regenerate intentionally
omitted — ACP has no native re-prompt primitive and implementing
one would be non-trivial session-state surgery.

Both schemes build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-24 13:43:20 +02:00
parent 5f9343be5d
commit 8282b1d604
2 changed files with 251 additions and 67 deletions
@@ -0,0 +1,92 @@
import Foundation
/// Splits a chat message body into alternating text + fenced-code
/// segments so ChatView can render each part appropriately. Text
/// gets the existing AttributedString(markdown:) path; code gets a
/// horizontally-scrollable monospaced block (pass-1 UX: long lines
/// wrapped onto 45 visual rows each, which ate vertical space and
/// made code unreadable on an iPhone).
///
/// Keeps the parser deliberately simple: we recognise the common
/// fenced form (```\n...\n``` and ```lang\n...\n```) and leave
/// everything else in the .text bucket. Inline `backticks` stay in
/// the text segment AttributedString handles those fine.
enum ChatContentFormatter {
enum Segment: Equatable {
case text(String)
case code(language: String?, body: String)
}
/// Split the given message body into an ordered list of segments.
/// A body with no fenced code yields a single `.text` segment.
static func segments(for body: String) -> [Segment] {
// Fast path: no fences at all.
guard body.contains("```") else { return [.text(body)] }
var result: [Segment] = []
var pending = ""
var i = body.startIndex
while i < body.endIndex {
// Try to match a fence opening at this position.
if body[i...].hasPrefix("```") {
// Flush the accumulated text.
if !pending.isEmpty {
result.append(.text(pending))
pending = ""
}
// Parse the optional language token up to the first newline.
let afterFence = body.index(i, offsetBy: 3)
var j = afterFence
while j < body.endIndex, body[j] != "\n" {
j = body.index(after: j)
}
let lang = String(body[afterFence..<j]).trimmingCharacters(in: .whitespaces)
// Skip the newline after the language line, if any.
let bodyStart = (j < body.endIndex) ? body.index(after: j) : j
// Scan for the closing fence.
var k = bodyStart
while k < body.endIndex {
if body[k...].hasPrefix("```") {
break
}
k = body.index(after: k)
}
let codeBody = String(body[bodyStart..<k])
result.append(.code(
language: lang.isEmpty ? nil : lang,
body: codeBody.hasSuffix("\n") ? String(codeBody.dropLast()) : codeBody
))
if k < body.endIndex {
// Skip the closing ```.
i = body.index(k, offsetBy: 3)
// Skip a single trailing newline if present so
// the next text segment doesn't start with a
// cosmetic blank line.
if i < body.endIndex, body[i] == "\n" {
i = body.index(after: i)
}
} else {
// Unterminated fence keep everything we saw
// as text instead, preserving user input rather
// than silently swallowing it.
pending = String(body[i..<body.endIndex])
i = body.endIndex
}
} else {
pending.append(body[i])
i = body.index(after: i)
}
}
if !pending.isEmpty {
result.append(.text(pending))
}
return result
}
}
+159 -67
View File
@@ -85,50 +85,46 @@ struct ChatView: View {
@ViewBuilder
private var messageList: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 12) {
if controller.vm.messages.isEmpty, controller.state == .ready {
emptyState
}
ForEach(controller.vm.messages) { msg in
MessageBubble(message: msg)
.id(msg.id)
}
if controller.vm.isGenerating {
HStack {
ProgressView()
Text("Agent is thinking…")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
} else if controller.vm.isPostProcessing {
HStack(spacing: 6) {
Image(systemName: "ellipsis")
.font(.caption2)
.foregroundStyle(.tertiary)
Text("Finishing up…")
.font(.caption2)
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
}
Color.clear
.frame(height: 1)
.id("bottom")
ScrollView {
LazyVStack(spacing: 12) {
if controller.vm.messages.isEmpty, controller.state == .ready {
emptyState
}
ForEach(controller.vm.messages) { msg in
MessageBubble(message: msg)
.id(msg.id)
}
if controller.vm.isGenerating {
HStack {
ProgressView()
Text("Agent is thinking…")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
} else if controller.vm.isPostProcessing {
HStack(spacing: 6) {
Image(systemName: "ellipsis")
.font(.caption2)
.foregroundStyle(.tertiary)
Text("Finishing up…")
.font(.caption2)
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
}
.padding(.vertical)
}
.onChange(of: controller.vm.scrollTrigger) { _, _ in
withAnimation { proxy.scrollTo("bottom", anchor: .bottom) }
}
.onChange(of: controller.vm.messages.count) { _, _ in
withAnimation { proxy.scrollTo("bottom", anchor: .bottom) }
}
.padding(.vertical)
}
// iOS 17+ keeps the scroll pinned to the newest content at
// the bottom; iOS 18's `.sizeChanges` variant also tracks
// when a message grows (streaming chunks, Expand-all on a
// code block). Replaces the old manual proxy.scrollTo dance
// which fought with the user's own scroll gestures.
.defaultScrollAnchor(.bottom)
.defaultScrollAnchor(.bottom, for: .sizeChanges)
}
@ViewBuilder
@@ -478,36 +474,132 @@ private struct MessageBubble: View {
@ViewBuilder
private var bubbleContent: some View {
// Render markdown on the assistant side so bold/code/links
// look right. User messages stay plain no reason to parse
// what the user just typed. AttributedString(markdown:) is
// conservative unknown constructs fall through as literal
// text, so the worst case is just "no formatting".
let text: Text = {
if message.isUser {
return Text(message.content)
// User bubbles are plain text no reason to parse what the
// user just typed. Assistant bubbles route through the
// ChatContentFormatter so fenced code blocks get horizontal
// scrolling instead of soft-wrapping into ugly 4-line
// vertical columns on an iPhone.
if message.isUser {
Text(message.content)
.font(.body)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.foregroundStyle(.white)
.background(Color.accentColor)
.clipShape(RoundedRectangle(cornerRadius: 14))
.textSelection(.enabled)
.contextMenu { messageContextMenu }
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(ChatContentFormatter.segments(for: message.content).enumerated()), id: \.offset) { _, segment in
switch segment {
case .text(let body):
Self.markdownText(body)
.font(.body)
.textSelection(.enabled)
case .code(let lang, let body):
CodeBlockView(language: lang, body: body)
}
}
}
if let attributed = try? AttributedString(
markdown: message.content,
options: AttributedString.MarkdownParsingOptions(
interpretedSyntax: .inlineOnlyPreservingWhitespace
)
) {
return Text(attributed)
}
return Text(message.content)
}()
text
.font(.body)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.foregroundStyle(message.isUser ? Color.white : Color.primary)
.background(
message.isUser ? Color.accentColor : Color(.secondarySystemBackground)
)
.foregroundStyle(Color.primary)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 14))
.textSelection(.enabled)
.contextMenu { messageContextMenu }
}
}
/// Shared context-menu actions for user + assistant bubbles.
/// Copy is the most-used action; Share hands off to the system
/// share sheet via ShareLink. Regenerate is intentionally absent
/// ACP doesn't support it natively and the pattern would require
/// non-trivial session-state surgery.
@ViewBuilder
private var messageContextMenu: some View {
Button {
UIPasteboard.general.string = message.content
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
ShareLink(item: message.content) {
Label("Share", systemImage: "square.and.arrow.up")
}
}
/// Parses message text as markdown for the assistant side. Text-
/// only segments coming from ChatContentFormatter can contain
/// inline backticks / bold / links; `.inlineOnlyPreservingWhitespace`
/// preserves newlines + spacing and won't mangle the output if
/// the input isn't valid markdown.
private static func markdownText(_ body: String) -> Text {
if let attributed = try? AttributedString(
markdown: body,
options: AttributedString.MarkdownParsingOptions(
interpretedSyntax: .inlineOnlyPreservingWhitespace
)
) {
return Text(attributed)
}
return Text(body)
}
}
/// Horizontally-scrollable fenced code block. ~240pt max height
/// collapsed (Expand button reveals full height). Monospaced
/// .footnote font keeps the bubble narrow enough to still show
/// adjacent text on the same screen. Language label is a tiny
/// header when present.
private struct CodeBlockView: View {
let language: String?
let code: String
@State private var expanded = false
private let collapsedMaxHeight: CGFloat = 240
init(language: String?, body: String) {
self.language = language
self.code = body
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
if let lang = language, !lang.isEmpty {
Text(lang.uppercased())
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
}
Spacer()
Button(expanded ? "Collapse" : "Expand") {
withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() }
}
.font(.caption2)
.buttonStyle(.plain)
.foregroundStyle(.tint)
Button {
UIPasteboard.general.string = code
} label: {
Image(systemName: "doc.on.doc")
.font(.caption2)
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
}
ScrollView(.horizontal, showsIndicators: true) {
Text(code)
.font(.footnote.monospaced())
.textSelection(.enabled)
.padding(.horizontal, 8)
.padding(.vertical, 6)
.fixedSize(horizontal: true, vertical: false)
}
.frame(maxHeight: expanded ? nil : collapsedMaxHeight)
.background(Color(.tertiarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}