mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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:
@@ -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 4–5 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,7 +85,6 @@ struct ChatView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var messageList: some View {
|
private var messageList: some View {
|
||||||
ScrollViewReader { proxy in
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 12) {
|
LazyVStack(spacing: 12) {
|
||||||
if controller.vm.messages.isEmpty, controller.state == .ready {
|
if controller.vm.messages.isEmpty, controller.state == .ready {
|
||||||
@@ -116,19 +115,16 @@ struct ChatView: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
Color.clear
|
|
||||||
.frame(height: 1)
|
|
||||||
.id("bottom")
|
|
||||||
}
|
}
|
||||||
.padding(.vertical)
|
.padding(.vertical)
|
||||||
}
|
}
|
||||||
.onChange(of: controller.vm.scrollTrigger) { _, _ in
|
// iOS 17+ keeps the scroll pinned to the newest content at
|
||||||
withAnimation { proxy.scrollTo("bottom", anchor: .bottom) }
|
// the bottom; iOS 18's `.sizeChanges` variant also tracks
|
||||||
}
|
// when a message grows (streaming chunks, Expand-all on a
|
||||||
.onChange(of: controller.vm.messages.count) { _, _ in
|
// code block). Replaces the old manual proxy.scrollTo dance
|
||||||
withAnimation { proxy.scrollTo("bottom", anchor: .bottom) }
|
// which fought with the user's own scroll gestures.
|
||||||
}
|
.defaultScrollAnchor(.bottom)
|
||||||
}
|
.defaultScrollAnchor(.bottom, for: .sizeChanges)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -478,36 +474,132 @@ private struct MessageBubble: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var bubbleContent: some View {
|
private var bubbleContent: some View {
|
||||||
// Render markdown on the assistant side so bold/code/links
|
// User bubbles are plain text — no reason to parse what the
|
||||||
// look right. User messages stay plain — no reason to parse
|
// user just typed. Assistant bubbles route through the
|
||||||
// what the user just typed. AttributedString(markdown:) is
|
// ChatContentFormatter so fenced code blocks get horizontal
|
||||||
// conservative — unknown constructs fall through as literal
|
// scrolling instead of soft-wrapping into ugly 4-line
|
||||||
// text, so the worst case is just "no formatting".
|
// vertical columns on an iPhone.
|
||||||
let text: Text = {
|
|
||||||
if message.isUser {
|
if message.isUser {
|
||||||
return Text(message.content)
|
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)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.foregroundStyle(Color.primary)
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.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(
|
if let attributed = try? AttributedString(
|
||||||
markdown: message.content,
|
markdown: body,
|
||||||
options: AttributedString.MarkdownParsingOptions(
|
options: AttributedString.MarkdownParsingOptions(
|
||||||
interpretedSyntax: .inlineOnlyPreservingWhitespace
|
interpretedSyntax: .inlineOnlyPreservingWhitespace
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return Text(attributed)
|
return Text(attributed)
|
||||||
}
|
}
|
||||||
return Text(message.content)
|
return Text(body)
|
||||||
}()
|
}
|
||||||
|
}
|
||||||
|
|
||||||
text
|
/// Horizontally-scrollable fenced code block. ~240pt max height
|
||||||
.font(.body)
|
/// collapsed (Expand button reveals full height). Monospaced
|
||||||
.padding(.horizontal, 12)
|
/// .footnote font keeps the bubble narrow enough to still show
|
||||||
.padding(.vertical, 8)
|
/// adjacent text on the same screen. Language label is a tiny
|
||||||
.foregroundStyle(message.isUser ? Color.white : Color.primary)
|
/// header when present.
|
||||||
.background(
|
private struct CodeBlockView: View {
|
||||||
message.isUser ? Color.accentColor : Color(.secondarySystemBackground)
|
let language: String?
|
||||||
)
|
let code: String
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
@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)
|
.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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user