chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
+195
View File
@@ -0,0 +1,195 @@
import Foundation
import JCodeKit
import Observation
/// Observable glue between JCodeKit and the SwiftUI views.
///
/// Owns the credential store, the active `Connection`, and the derived
/// `SessionState`. Contains no protocol or state-transition logic itself;
/// everything flows through `SessionReducer`.
@MainActor
@Observable
final class AppModel {
// MARK: - Published state
private(set) var session = SessionState()
private(set) var servers: [ServerCredential] = []
var activeServer: ServerCredential?
/// Composer draft.
var draft = ""
// MARK: - Internals
private let store: any CredentialStore
private var connection: Connection?
private var pumpTask: Task<Void, Never>?
init(store: any CredentialStore = KeychainCredentialStore()) {
self.store = store
servers = store.loadAll()
activeServer = servers.last
}
var isConnected: Bool {
session.phase == .connected
}
// MARK: - Pairing
func pair(gateway: Gateway, code: String, deviceName: String) async throws {
let client = PairingClient()
let response = try await client.pair(
gateway: gateway,
code: code,
deviceID: deviceID(),
deviceName: deviceName
)
let credential = ServerCredential(
host: gateway.host,
port: gateway.port,
token: response.token,
serverName: response.serverName,
serverVersion: response.serverVersion
)
store.save(credential)
servers = store.loadAll()
activeServer = credential
connect(to: credential)
}
func removeServer(_ credential: ServerCredential) {
store.remove(id: credential.id)
servers = store.loadAll()
if activeServer?.id == credential.id {
disconnect()
activeServer = servers.last
}
}
// MARK: - Connection lifecycle
func connect(to credential: ServerCredential, sessionID: String? = nil) {
session = SessionState()
open(credential, sessionID: sessionID)
}
/// Reconnects to the active server without discarding the rendered
/// transcript; the history resync replaces it once the socket is back.
func retryConnection() {
guard let activeServer else { return }
open(activeServer, sessionID: session.sessionID)
}
private func open(_ credential: ServerCredential, sessionID: String?) {
disconnect()
activeServer = credential
let connection = Connection(
configuration: .init(
gateway: credential.gateway,
authToken: credential.token
)
)
self.connection = connection
pumpTask = Task { [weak self] in
let stream = await connection.start(resumeSessionID: sessionID)
for await output in stream {
guard let self else { return }
self.session = SessionReducer.reduce(self.session, output)
}
}
}
func disconnect() {
pumpTask?.cancel()
pumpTask = nil
let connection = connection
self.connection = nil
Task { await connection?.stop() }
session = SessionReducer.reduce(session, .phase(.disconnected))
}
// MARK: - Actions
func sendDraft() {
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
draft = ""
if session.isProcessing {
session = SessionReducer.reduce(session, intent: .userQueuedInterrupt(text))
send { .softInterrupt(id: $0, content: text, urgent: false) }
} else {
session = SessionReducer.reduce(session, intent: .userSentMessage(text))
send { .message(id: $0, content: text) }
}
}
func interrupt() {
send { .cancel(id: $0) }
}
func switchSession(_ sessionID: String) {
guard let activeServer else { return }
connect(to: activeServer, sessionID: sessionID)
}
func setModel(_ model: String) {
send { .setModel(id: $0, model: model) }
}
func setReasoningEffort(_ effort: String) {
send { .setReasoningEffort(id: $0, effort: effort) }
}
/// Asks the server to compact the conversation context.
func compactConversation() {
send { .compact(id: $0) }
}
func renameSession(_ title: String) {
send { .renameSession(id: $0, title: title.isEmpty ? nil : title) }
}
func dismissError() {
session = SessionReducer.reduce(session, intent: .dismissError)
}
func dismissNotice(_ id: UUID) {
session = SessionReducer.reduce(session, intent: .dismissNotice(id))
}
/// Clears the current conversation on the server and optimistically locally.
func clearConversation() {
session = SessionReducer.reduce(session, intent: .clearedConversation)
send { .clear(id: $0) }
}
/// Drops any soft-interrupt messages queued mid-run before they inject.
func cancelQueuedInterrupts() {
session = SessionReducer.reduce(session, intent: .cancelledQueuedInterrupts)
send { .cancelSoftInterrupts(id: $0) }
}
// MARK: - Helpers
private func send(_ build: @escaping @Sendable (UInt64) -> Request) {
guard let connection else { return }
Task {
do {
try await connection.send(build)
} catch {
// Connection drops surface via phase changes; nothing to do here.
}
}
}
private func deviceID() -> String {
let key = "jcode.device.id"
if let existing = UserDefaults.standard.string(forKey: key) {
return existing
}
let fresh = UUID().uuidString
UserDefaults.standard.set(fresh, forKey: key)
return fresh
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

@@ -0,0 +1,14 @@
{
"images": [
{
"filename": "AppIcon.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,20 @@
{
"colors": [
{
"color": {
"color-space": "srgb",
"components": {
"alpha": "1.000",
"blue": "0x14",
"green": "0x0F",
"red": "0x0F"
}
},
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
+55
View File
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleDisplayName</key>
<string>jcode</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>LaunchBackground</string>
</dict>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.jcode.mobile.pair</string>
<key>CFBundleURLSchemes</key>
<array>
<string>jcode</string>
</array>
</dict>
</array>
<key>NSCameraUsageDescription</key>
<string>Scan the QR code shown by `jcode pair` to connect to your server.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSLocalNetworkUsageDescription</key>
<string>Connects to jcode servers on your local network or tailnet.</string>
</dict>
</plist>
@@ -0,0 +1,14 @@
import SwiftUI
@main
struct JCodeMobileApp: App {
@State private var model = AppModel()
var body: some Scene {
WindowGroup {
RootView()
.environment(model)
.preferredColorScheme(.dark)
}
}
}
@@ -0,0 +1,92 @@
import SwiftUI
/// Lightweight markdown renderer for assistant messages.
///
/// Handles fenced code blocks as monospaced cards and renders everything else
/// through SwiftUI's native AttributedString markdown (bold, italics, inline
/// code, links). Deliberately not a full CommonMark implementation.
struct MarkdownText: View {
let text: String
init(_ text: String) {
self.text = text
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(segments.enumerated()), id: \.offset) { _, segment in
switch segment {
case .prose(let prose):
Text(attributed(prose))
.font(.body)
.foregroundStyle(Theme.textPrimary)
.textSelection(.enabled)
case .code(let code, _):
ScrollView(.horizontal, showsIndicators: false) {
Text(code)
.font(Theme.mono(12))
.foregroundStyle(Theme.textSecondary)
.padding(12)
.textSelection(.enabled)
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(Theme.surfaceElevated)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
}
}
private enum Segment {
case prose(String)
case code(String, language: String?)
}
private var segments: [Segment] {
var result: [Segment] = []
var prose: [String] = []
var code: [String] = []
var language: String?
var inCode = false
for line in text.split(separator: "\n", omittingEmptySubsequences: false) {
if line.hasPrefix("```") {
if inCode {
result.append(.code(code.joined(separator: "\n"), language: language))
code = []
inCode = false
} else {
let joined = prose.joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
if !joined.isEmpty {
result.append(.prose(joined))
}
prose = []
language = line.dropFirst(3).isEmpty ? nil : String(line.dropFirst(3))
inCode = true
}
} else if inCode {
code.append(String(line))
} else {
prose.append(String(line))
}
}
if inCode {
result.append(.code(code.joined(separator: "\n"), language: language))
} else {
let joined = prose.joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
if !joined.isEmpty {
result.append(.prose(joined))
}
}
return result
}
private func attributed(_ string: String) -> AttributedString {
(try? AttributedString(
markdown: string,
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
)) ?? AttributedString(string)
}
}
+103
View File
@@ -0,0 +1,103 @@
import AVFoundation
import SwiftUI
/// Camera-based QR scanner for pairing codes.
struct QRScannerView: UIViewControllerRepresentable {
let onScan: (String) -> Void
func makeUIViewController(context: Context) -> ScannerViewController {
let controller = ScannerViewController()
controller.onScan = onScan
return controller
}
func updateUIViewController(_ controller: ScannerViewController, context: Context) {}
}
final class ScannerViewController: UIViewController, @preconcurrency AVCaptureMetadataOutputObjectsDelegate {
var onScan: ((String) -> Void)?
private let session = AVCaptureSession()
private var previewLayer: AVCaptureVideoPreviewLayer?
private var didScan = false
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input)
else {
showUnavailableLabel()
return
}
session.addInput(input)
let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else {
showUnavailableLabel()
return
}
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
output.metadataObjectTypes = [.qr]
let preview = AVCaptureVideoPreviewLayer(session: session)
preview.videoGravity = .resizeAspectFill
preview.frame = view.bounds
view.layer.addSublayer(preview)
previewLayer = preview
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
previewLayer?.frame = view.bounds
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
didScan = false
let session = session
DispatchQueue.global(qos: .userInitiated).async {
if !session.isRunning {
session.startRunning()
}
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
let session = session
DispatchQueue.global(qos: .userInitiated).async {
if session.isRunning {
session.stopRunning()
}
}
}
func metadataOutput(
_ output: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection
) {
guard !didScan,
let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
object.type == .qr,
let value = object.stringValue
else { return }
didScan = true
UINotificationFeedbackGenerator().notificationOccurred(.success)
onScan?(value)
}
private func showUnavailableLabel() {
let label = UILabel()
label.text = "Camera unavailable"
label.textColor = .white
label.textAlignment = .center
label.frame = view.bounds
label.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(label)
}
}
+77
View File
@@ -0,0 +1,77 @@
import SwiftUI
/// Design tokens. Dark, calm, terminal-native; mint accent for live state.
enum Theme {
static let background = Color(hex: 0x0F0F14)
static let surface = Color(hex: 0x1A1A1F)
static let surfaceElevated = Color(hex: 0x242429)
static let border = Color.white.opacity(0.08)
static let mint = Color(hex: 0x4DD9A6)
static let mintTint = Color(hex: 0x4DD9A6).opacity(0.15)
static let textPrimary = Color.white.opacity(0.92)
static let textSecondary = Color.white.opacity(0.55)
static let textTertiary = Color.white.opacity(0.35)
static let warning = Color(hex: 0xF59E0B)
static let error = Color(hex: 0xD94D59)
static func mono(_ size: CGFloat, weight: Font.Weight = .regular) -> Font {
.system(size: size, weight: weight, design: .monospaced)
}
/// Decorative icon font (SF Symbols) at a fixed point size.
static func icon(_ size: CGFloat, weight: Font.Weight = .regular) -> Font {
.system(size: size, weight: weight)
}
}
extension Color {
init(hex: UInt32) {
self.init(
red: Double((hex >> 16) & 0xFF) / 255.0,
green: Double((hex >> 8) & 0xFF) / 255.0,
blue: Double(hex & 0xFF) / 255.0
)
}
}
/// Extra edge padding for chrome pinned to an edge with no system inset.
///
/// Home-button devices (iPhone SE class) report a zero bottom safe-area inset,
/// so edge-pinned chrome needs explicit breathing room there; Dynamic Island
/// devices already get it from the system insets. Derived from the root
/// GeometryReader in RootView and injected via the environment: reading
/// UIKit window insets during a SwiftUI body evaluation creates an
/// AttributeGraph cycle that corrupts view-hierarchy updates.
struct CompactEdgePads: Equatable {
var top: CGFloat = 0
var bottom: CGFloat = 0
/// Derives the pads from the container's safe-area insets.
init(safeArea: EdgeInsets) {
top = safeArea.top < 24 ? 12 : 0
bottom = safeArea.bottom > 0 ? 0 : 12
}
init() {}
}
extension EnvironmentValues {
@Entry var compactEdgePads = CompactEdgePads()
}
/// Card container used across screens.
struct Card<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
content
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Theme.surface)
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(Theme.border, lineWidth: 1)
)
}
}
@@ -0,0 +1,125 @@
import JCodeKit
import SwiftUI
/// Main conversation screen.
struct ChatView: View {
@Environment(AppModel.self) private var model
@Environment(\.compactEdgePads) private var edgePads
@State private var showSettings = false
@State private var sendCount = 0
var body: some View {
@Bindable var model = model
VStack(spacing: 0) {
header
if showConnectionBanner {
ConnectionBanner(phase: model.session.phase) {
model.retryConnection()
}
.padding(.bottom, 8)
}
if let banner = model.session.errorBanner {
ErrorBanner(message: banner) {
model.dismissError()
}
.padding(.bottom, 8)
}
if !model.session.notices.isEmpty {
NoticeStack(
notices: model.session.notices,
onDismiss: { model.dismissNotice($0) }
)
.padding(.bottom, 8)
}
TranscriptView(
entries: model.session.transcript,
isReasoning: model.session.isReasoning
)
if model.session.hasPendingInterrupts {
QueuedInterruptChip(count: model.session.pendingInterrupts.count) {
model.cancelQueuedInterrupts()
}
.padding(.bottom, 8)
}
Composer(
draft: $model.draft,
isProcessing: model.session.isProcessing,
isConnected: model.isConnected,
onSend: {
sendCount += 1
model.sendDraft()
},
onInterrupt: { model.interrupt() }
)
}
.sheet(isPresented: $showSettings) {
SettingsView()
}
.sensoryFeedback(.impact(weight: .light), trigger: sendCount)
.sensoryFeedback(.impact(flexibility: .soft), trigger: finishedToolCallCount) {
$1 > $0
}
.sensoryFeedback(.error, trigger: model.session.errorBanner) {
$1 != nil
}
}
private var showConnectionBanner: Bool {
switch model.session.phase {
case .reconnecting, .disconnected, .failed: true
case .connected, .connecting: false
}
}
/// Finished tool calls on the streaming (last) entry; drives a subtle
/// tick as tools complete without scanning the whole transcript.
private var finishedToolCallCount: Int {
model.session.transcript.last?.toolCalls.filter { call in
switch call.status {
case .succeeded, .failed: true
case .streamingInput, .running: false
}
}.count ?? 0
}
private var header: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(model.session.sessionTitle ?? model.activeServer?.serverName ?? "jcode")
.font(Theme.mono(16, weight: .semibold))
.foregroundStyle(Theme.textPrimary)
.lineLimit(1)
if let modelName = model.session.modelName {
Text(modelName)
.font(Theme.mono(11))
.foregroundStyle(Theme.textTertiary)
.lineLimit(1)
}
}
Spacer()
StatusPill(phase: model.session.phase)
Button {
showSettings = true
} label: {
Image(systemName: "ellipsis")
.font(.body.weight(.semibold))
.foregroundStyle(Theme.textSecondary)
.frame(width: 44, height: 44)
.background(Theme.surfaceElevated)
.clipShape(Circle())
.overlay(Circle().stroke(Theme.border, lineWidth: 1))
}
.accessibilityLabel("Settings")
.accessibilityHint("Sessions, model, and servers")
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.padding(.top, edgePads.top)
}
}
@@ -0,0 +1,64 @@
import SwiftUI
/// Message composer with send/interrupt.
struct Composer: View {
@Environment(\.compactEdgePads) private var edgePads
@Binding var draft: String
let isProcessing: Bool
let isConnected: Bool
let onSend: () -> Void
let onInterrupt: () -> Void
var body: some View {
HStack(alignment: .bottom, spacing: 8) {
TextField(
isProcessing ? "Queue a message..." : "Message",
text: $draft,
axis: .vertical
)
.lineLimit(1...6)
.font(.body)
.foregroundStyle(Theme.textPrimary)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Theme.surface)
.clipShape(RoundedRectangle(cornerRadius: 20))
.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(Theme.border, lineWidth: 1)
)
if isProcessing {
Button(action: onInterrupt) {
Image(systemName: "stop.fill")
.font(.body.weight(.semibold))
.foregroundStyle(Theme.error)
.frame(width: 44, height: 44)
.background(Theme.surface)
.clipShape(Circle())
}
.accessibilityLabel("Stop")
.accessibilityHint("Interrupt the current response")
}
Button(action: onSend) {
Image(systemName: "arrow.up")
.font(.body.weight(.bold))
.foregroundStyle(isConnected ? .black : Theme.textSecondary)
.frame(width: 44, height: 44)
.background(isConnected ? Theme.mint : Theme.surfaceElevated)
.clipShape(Circle())
}
.disabled(!canSend)
.accessibilityLabel(isProcessing ? "Queue message" : "Send message")
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.padding(.bottom, edgePads.bottom)
.background(Theme.background)
}
private var canSend: Bool {
isConnected && !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
@@ -0,0 +1,90 @@
import JCodeKit
import SwiftUI
/// Inline banner shown while the connection is down, with a manual retry.
///
/// The automatic reconnect loop keeps running underneath; the button just
/// short-circuits the backoff wait for impatient humans.
struct ConnectionBanner: View {
let phase: ConnectionPhase
let onRetry: () -> Void
var body: some View {
HStack(spacing: 8) {
Image(systemName: "wifi.slash")
.font(.footnote)
.foregroundStyle(Theme.warning)
.accessibilityHidden(true)
Text(label)
.font(.footnote)
.foregroundStyle(Theme.textPrimary)
.lineLimit(2)
Spacer(minLength: 0)
Button(action: onRetry) {
Text("Retry")
.font(.footnote.weight(.semibold))
.foregroundStyle(Theme.mint)
.frame(minWidth: 44, minHeight: 44)
}
.accessibilityLabel("Retry connection")
.accessibilityHint("Reconnects to the server now")
}
.padding(.horizontal, 12)
.background(Theme.warning.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(Theme.warning.opacity(0.35), lineWidth: 1)
)
.padding(.horizontal)
.accessibilityElement(children: .combine)
}
private var label: String {
switch phase {
case .reconnecting(let attempt):
"Connection lost, retrying (attempt \(attempt))"
case .failed:
"Connection failed"
default:
"Offline"
}
}
}
/// Chip shown above the composer while soft-interrupt messages wait to be
/// injected into the running turn, with a cancel affordance.
struct QueuedInterruptChip: View {
let count: Int
let onCancel: () -> Void
var body: some View {
HStack(spacing: 8) {
Image(systemName: "clock")
.font(.footnote)
.foregroundStyle(Theme.mint)
.accessibilityHidden(true)
Text(count == 1 ? "1 message queued" : "\(count) messages queued")
.font(.footnote)
.foregroundStyle(Theme.textPrimary)
Spacer(minLength: 0)
Button(action: onCancel) {
Text("Cancel")
.font(.footnote.weight(.semibold))
.foregroundStyle(Theme.error)
.frame(minWidth: 44, minHeight: 44)
}
.accessibilityLabel("Cancel queued messages")
.accessibilityHint("Removes messages waiting to interrupt the response")
}
.padding(.horizontal, 12)
.background(Theme.mintTint)
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(Theme.mint.opacity(0.35), lineWidth: 1)
)
.padding(.horizontal)
.accessibilityElement(children: .combine)
}
}
@@ -0,0 +1,89 @@
import JCodeKit
import SwiftUI
/// Friendly placeholder for a fresh session, centered in the canvas.
struct EmptyTranscript: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "terminal")
.font(Theme.icon(40, weight: .light))
.foregroundStyle(Theme.textSecondary)
Text("Ready when you are")
.font(Theme.mono(16, weight: .medium))
.foregroundStyle(Theme.textPrimary)
Text("Send a message to start driving this session.")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary)
.multilineTextAlignment(.center)
}
.padding(32)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
/// One transcript entry: user bubble, assistant markdown, or system note.
struct EntryView: View {
let entry: TranscriptEntry
var body: some View {
switch entry.role {
case .user:
HStack {
Spacer(minLength: 48)
VStack(alignment: .trailing, spacing: 4) {
Text(entry.text)
.font(.body)
.foregroundStyle(Theme.textPrimary)
.padding(12)
.background(Theme.mintTint)
.clipShape(RoundedRectangle(cornerRadius: 16))
.copyContextMenu(entry.text)
if entry.isQueued {
Label("queued", systemImage: "clock")
.font(Theme.mono(11))
.foregroundStyle(Theme.textTertiary)
.accessibilityLabel("Queued")
.accessibilityHint("Delivers after the current response")
}
}
}
case .assistant:
VStack(alignment: .leading, spacing: 8) {
if !entry.reasoning.isEmpty {
Text(entry.reasoning)
.font(Theme.mono(12))
.italic()
.foregroundStyle(Theme.textTertiary)
.lineLimit(4)
.copyContextMenu(entry.reasoning)
}
ForEach(entry.toolCalls) { call in
ToolCallCard(call: call)
}
if !entry.text.isEmpty {
MarkdownText(entry.text)
.copyContextMenu(entry.text)
}
}
case .system:
Text(entry.text)
.font(.footnote)
.foregroundStyle(Theme.textTertiary)
.frame(maxWidth: .infinity, alignment: .center)
.copyContextMenu(entry.text)
}
}
}
extension View {
/// Long-press context menu offering to copy the given text.
func copyContextMenu(_ text: String) -> some View {
contextMenu {
Button {
UIPasteboard.general.string = text
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
}
}
}
@@ -0,0 +1,160 @@
import JCodeKit
import SwiftUI
/// First-run pairing: scan QR or type host/port/code.
struct PairingView: View {
@Environment(AppModel.self) private var model
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var host = ""
@State private var port = String(Gateway.defaultPort)
@State private var code = ""
@State private var isPairing = false
@State private var errorMessage: String?
@State private var showScanner = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
header
if let errorMessage {
ErrorBanner(message: errorMessage) {
self.errorMessage = nil
}
.padding(.horizontal, -16)
}
Card {
VStack(alignment: .leading, spacing: 16) {
field("Host", text: $host, placeholder: "devbox.tailnet.ts.net")
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
field("Port", text: $port, placeholder: "7643")
.keyboardType(.numberPad)
field("Pairing code", text: $code, placeholder: "123456")
.keyboardType(.numberPad)
}
}
Button(action: pair) {
HStack {
if isPairing && !reduceMotion {
ProgressView().tint(.black)
}
Text(isPairing ? "Pairing..." : "Pair")
.font(.headline)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(canPair ? Theme.mint : Theme.surfaceElevated)
.foregroundStyle(canPair ? .black : Theme.textTertiary)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(!canPair || isPairing)
.accessibilityLabel("Pair")
.accessibilityHint("Connects using the host, port, and code above")
Button {
showScanner = true
} label: {
Label("Scan QR from `jcode pair`", systemImage: "qrcode.viewfinder")
.font(.subheadline)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Theme.surface)
.foregroundStyle(Theme.textPrimary)
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(Theme.border, lineWidth: 1)
)
}
.accessibilityLabel("Scan QR code")
.accessibilityHint("Opens the camera to scan a pairing code")
Text("Run `jcode pair` on your machine, then scan the QR code or enter the code manually. Traffic stays on your tailnet.")
.font(.footnote)
.foregroundStyle(Theme.textTertiary)
}
.padding(16)
}
.scrollDismissesKeyboard(.interactively)
.dynamicTypeSize(.large ... .accessibility3)
.sheet(isPresented: $showScanner) {
QRScannerView { scanned in
showScanner = false
if let payload = PairURI.parse(scanned) {
host = payload.gateway.host
port = String(payload.gateway.port)
code = payload.code
pair()
} else {
errorMessage = "Not a jcode pairing QR code"
}
}
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 8) {
Text("jcode")
.font(Theme.mono(34, weight: .bold))
.foregroundStyle(Theme.textPrimary)
Text("Pair with a server on your tailnet")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary)
}
.padding(.top, 32)
}
private var canPair: Bool {
!host.trimmingCharacters(in: .whitespaces).isEmpty && !code.isEmpty
&& UInt16(port) != nil
}
private func field(_ label: String, text: Binding<String>, placeholder: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(label)
.font(.caption)
.foregroundStyle(Theme.textTertiary)
TextField(placeholder, text: text)
.font(Theme.mono(16))
.foregroundStyle(Theme.textPrimary)
.padding(12)
.background(Theme.surfaceElevated)
.clipShape(RoundedRectangle(cornerRadius: 10))
.accessibilityLabel(label)
}
}
private func pair() {
guard let portValue = UInt16(port) else { return }
let gateway = Gateway(host: host.trimmingCharacters(in: .whitespaces), port: portValue)
let pairCode = code
isPairing = true
errorMessage = nil
Task {
defer { isPairing = false }
do {
try await model.pair(
gateway: gateway,
code: pairCode,
deviceName: UIDevice.current.name
)
} catch let error as PairingClient.PairingError {
switch error {
case .invalidCode(let message):
errorMessage = message
case .serverError(_, let message):
errorMessage = message
case .invalidResponse:
errorMessage = "Unexpected response from server"
}
} catch {
errorMessage = "Could not reach \(gateway.host):\(gateway.port)"
}
}
}
}
@@ -0,0 +1,200 @@
import JCodeKit
import SwiftUI
/// Top-level router: pairing when no server, chat otherwise.
struct RootView: View {
@Environment(AppModel.self) private var model
@State private var deepLinkError: String?
var body: some View {
GeometryReader { proxy in
ZStack {
Theme.background.ignoresSafeArea()
if model.activeServer == nil {
PairingView()
} else {
ChatView()
}
}
.environment(\.compactEdgePads, CompactEdgePads(safeArea: proxy.safeAreaInsets))
}
.task {
// Auto-connect to the most recent server on launch.
if let server = model.activeServer, !model.isConnected {
model.connect(to: server)
}
}
.onOpenURL { url in
guard let payload = PairURI.parse(url.absoluteString) else { return }
Task {
do {
try await model.pair(
gateway: payload.gateway,
code: payload.code,
deviceName: UIDevice.current.name
)
} catch {
deepLinkError = "Pairing failed: \(error.localizedDescription)"
}
}
}
.alert("Pairing", isPresented: .init(
get: { deepLinkError != nil },
set: { if !$0 { deepLinkError = nil } }
)) {
Button("OK", role: .cancel) {}
} message: {
Text(deepLinkError ?? "")
}
}
}
/// Connection status pill shown in the chat header.
struct StatusPill: View {
let phase: ConnectionPhase
var body: some View {
HStack(spacing: 8) {
Circle()
.fill(color)
.frame(width: 8, height: 8)
.accessibilityHidden(true)
Text(label)
.font(Theme.mono(12))
.foregroundStyle(Theme.textSecondary)
}
.padding(.horizontal, 20)
.padding(.vertical, 4)
.background(Theme.surface)
.clipShape(Capsule())
.overlay(Capsule().stroke(Theme.border, lineWidth: 1))
.accessibilityElement(children: .ignore)
.accessibilityLabel("Connection")
.accessibilityValue(label)
}
private var color: Color {
switch phase {
case .connected: Theme.mint
case .connecting, .reconnecting: Theme.warning
case .disconnected, .failed: Theme.error
}
}
private var label: String {
switch phase {
case .connected: "live"
case .connecting: "connecting"
case .reconnecting(let attempt): "retry \(attempt)"
case .disconnected: "offline"
case .failed: "failed"
}
}
}
/// Dismissible error banner.
struct ErrorBanner: View {
let message: String
let dismiss: () -> Void
var body: some View {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Theme.error)
.accessibilityHidden(true)
Text(message)
.font(.footnote)
.foregroundStyle(Theme.textPrimary)
.lineLimit(3)
Spacer(minLength: 0)
Button(action: dismiss) {
Image(systemName: "xmark")
.font(.caption.weight(.semibold))
.foregroundStyle(Theme.textSecondary)
.frame(width: 44, height: 44)
}
.accessibilityLabel("Dismiss error")
.accessibilityHint("Hides this error message")
}
.padding(12)
.background(Theme.error.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(Theme.error.opacity(0.35), lineWidth: 1)
)
.padding(.horizontal)
.accessibilityElement(children: .combine)
}
}
/// Stack of dismissible notices for out-of-band server signals
/// (push notifications, interrupts, context compaction).
struct NoticeStack: View {
let notices: [Notice]
let onDismiss: (UUID) -> Void
var body: some View {
VStack(spacing: 4) {
ForEach(notices) { notice in
NoticeRow(notice: notice) { onDismiss(notice.id) }
}
}
.padding(.horizontal)
}
}
private struct NoticeRow: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let notice: Notice
let dismiss: () -> Void
var body: some View {
HStack(spacing: 8) {
Image(systemName: icon)
.foregroundStyle(tint)
.accessibilityHidden(true)
Text(notice.message)
.font(.footnote)
.foregroundStyle(Theme.textPrimary)
.lineLimit(3)
Spacer(minLength: 0)
Button(action: dismiss) {
Image(systemName: "xmark")
.font(.caption.weight(.semibold))
.foregroundStyle(Theme.textSecondary)
.frame(width: 44, height: 44)
}
.accessibilityLabel("Dismiss notice")
.accessibilityHint("Hides this notice")
}
.padding(12)
.background(tint.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(tint.opacity(0.35), lineWidth: 1)
)
.accessibilityElement(children: .combine)
// Honor Reduce Motion: skip the slide/fade for motion-sensitive users.
.transition(reduceMotion
? .opacity
: .move(edge: .top).combined(with: .opacity))
}
private var icon: String {
switch notice.kind {
case .info: "info.circle.fill"
case .notification: "bell.fill"
case .compaction: "arrow.down.right.and.arrow.up.left"
}
}
private var tint: Color {
switch notice.kind {
case .info: Theme.textSecondary
case .notification: Theme.mint
case .compaction: Theme.warning
}
}
}
@@ -0,0 +1,176 @@
import JCodeKit
import SwiftUI
/// Sessions, servers, and info sections, split out to keep view files small.
struct SettingsSessionsSection: View {
@Environment(AppModel.self) private var model
@Environment(\.dismiss) private var dismiss
@Binding var renameDraft: String
@Binding var showRename: Bool
var body: some View {
Section("Sessions") {
ForEach(model.session.allSessions, id: \.self) { sessionID in
sessionRow(sessionID)
}
Button {
renameDraft = model.session.sessionTitle ?? ""
showRename = true
} label: {
Label("Rename current session", systemImage: "pencil")
.foregroundStyle(Theme.textPrimary)
}
.listRowBackground(Theme.surface)
.accessibilityHint("Opens a field to rename the active session")
Button {
model.compactConversation()
} label: {
Label("Compact conversation", systemImage: "arrow.down.right.and.arrow.up.left")
.foregroundStyle(Theme.textPrimary)
}
.listRowBackground(Theme.surface)
.accessibilityHint("Summarizes older messages to free context")
Button {
model.clearConversation()
dismiss()
} label: {
Label("New session (clear)", systemImage: "square.and.pencil")
.foregroundStyle(Theme.mint)
}
.listRowBackground(Theme.surface)
.accessibilityHint("Clears the conversation and starts fresh")
}
}
private func sessionRow(_ sessionID: String) -> some View {
let isActive = sessionID == model.session.sessionID
let title = model.session.title(forSession: sessionID)
return Button {
model.switchSession(sessionID)
dismiss()
} label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
if let title {
Text(title)
.font(.body)
.foregroundStyle(Theme.textPrimary)
.lineLimit(1)
}
Text(shortSessionID(sessionID))
.font(Theme.mono(title == nil ? 13 : 11))
.foregroundStyle(title == nil ? Theme.textPrimary : Theme.textTertiary)
.lineLimit(1)
}
Spacer()
if isActive {
Image(systemName: "checkmark")
.font(.caption)
.foregroundStyle(Theme.mint)
.accessibilityHidden(true)
}
}
}
.listRowBackground(Theme.surface)
.accessibilityLabel("Session \(title ?? shortSessionID(sessionID))")
.accessibilityValue(isActive ? "Current" : "")
.accessibilityHint("Switches to this session")
.accessibilityAddTraits(isActive ? [.isSelected] : [])
}
private func shortSessionID(_ id: String) -> String {
if id.count > 24 {
return String(id.prefix(24)) + ""
}
return id
}
}
struct SettingsServersSection: View {
@Environment(AppModel.self) private var model
@Environment(\.dismiss) private var dismiss
@Binding var showPairNew: Bool
var body: some View {
Section("Servers") {
ForEach(model.servers) { server in
let isActive = server.id == model.activeServer?.id
Button {
model.connect(to: server)
dismiss()
} label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(server.serverName)
.font(.body)
.foregroundStyle(Theme.textPrimary)
Text("\(server.host):\(String(server.port))")
.font(Theme.mono(11))
.foregroundStyle(Theme.textTertiary)
}
Spacer()
if isActive {
Circle()
.fill(Theme.mint)
.frame(width: 8, height: 8)
.accessibilityHidden(true)
}
}
}
.listRowBackground(Theme.surface)
.accessibilityLabel(server.serverName)
.accessibilityValue(isActive ? "Connected" : "")
.accessibilityHint("Connects to this server")
.accessibilityAddTraits(isActive ? [.isSelected] : [])
.swipeActions {
Button(role: .destructive) {
model.removeServer(server)
} label: {
Label("Remove", systemImage: "trash")
}
}
}
Button {
showPairNew = true
} label: {
Label("Pair new server", systemImage: "plus")
.foregroundStyle(Theme.mint)
}
.listRowBackground(Theme.surface)
.accessibilityHint("Opens pairing to add a server")
}
}
}
struct SettingsInfoSection: View {
@Environment(AppModel.self) private var model
var body: some View {
Section("Info") {
row("Server version", model.session.serverVersion ?? "unknown")
row("Provider", model.session.providerName ?? "unknown")
row(
"Tokens",
"\(model.session.tokenInput) in / \(model.session.tokenOutput) out"
)
if let detail = model.session.statusDetail {
row("Status", detail)
}
}
}
private func row(_ label: String, _ value: String) -> some View {
HStack {
Text(label)
.font(.callout)
.foregroundStyle(Theme.textSecondary)
Spacer()
Text(value)
.font(Theme.mono(12))
.foregroundStyle(Theme.textTertiary)
.lineLimit(1)
}
.listRowBackground(Theme.surface)
.accessibilityElement(children: .combine)
}
}
@@ -0,0 +1,120 @@
import JCodeKit
import SwiftUI
/// Settings sheet: model picker, reasoning effort, sessions, servers, info.
struct SettingsView: View {
@Environment(AppModel.self) private var model
@Environment(\.dismiss) private var dismiss
@State var renameDraft = ""
@State var showRename = false
@State var showPairNew = false
/// Reasoning effort levels offered when the provider exposes the knob.
static let reasoningEfforts = ["none", "low", "medium", "high", "xhigh"]
var body: some View {
NavigationStack {
List {
modelSection
if model.session.reasoningEffort != nil {
reasoningSection
}
SettingsSessionsSection(renameDraft: $renameDraft, showRename: $showRename)
SettingsServersSection(showPairNew: $showPairNew)
SettingsInfoSection()
}
.scrollContentBackground(.hidden)
.background(Theme.background)
.dynamicTypeSize(.large ... .accessibility3)
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
.preferredColorScheme(.dark)
.alert("Rename session", isPresented: $showRename) {
TextField("Title", text: $renameDraft)
Button("Rename") {
model.renameSession(renameDraft)
}
Button("Cancel", role: .cancel) {}
}
.sheet(isPresented: $showPairNew) {
NavigationStack {
PairingView()
.background(Theme.background)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { showPairNew = false }
}
}
}
.preferredColorScheme(.dark)
}
.onChange(of: model.activeServer?.id) {
showPairNew = false
}
}
private var modelSection: some View {
Section("Model") {
ForEach(model.session.availableModels, id: \.self) { name in
let isActive = name == model.session.modelName
Button {
model.setModel(name)
} label: {
HStack {
Text(name)
.font(Theme.mono(13))
.foregroundStyle(Theme.textPrimary)
.lineLimit(1)
Spacer()
if isActive {
Image(systemName: "checkmark")
.font(.caption)
.foregroundStyle(Theme.mint)
.accessibilityHidden(true)
}
}
}
.listRowBackground(Theme.surface)
.accessibilityLabel("Model \(name)")
.accessibilityValue(isActive ? "Selected" : "")
.accessibilityHint("Selects this model")
.accessibilityAddTraits(isActive ? [.isSelected] : [])
}
}
}
private var reasoningSection: some View {
Section("Reasoning effort") {
ForEach(Self.reasoningEfforts, id: \.self) { effort in
let isActive = effort == model.session.reasoningEffort
Button {
model.setReasoningEffort(effort)
} label: {
HStack {
Text(effort)
.font(Theme.mono(13))
.foregroundStyle(Theme.textPrimary)
Spacer()
if isActive {
Image(systemName: "checkmark")
.font(.caption)
.foregroundStyle(Theme.mint)
.accessibilityHidden(true)
}
}
}
.listRowBackground(Theme.surface)
.accessibilityLabel("Reasoning effort \(effort)")
.accessibilityValue(isActive ? "Selected" : "")
.accessibilityHint("Sets how much the model reasons before answering")
.accessibilityAddTraits(isActive ? [.isSelected] : [])
}
}
}
}
@@ -0,0 +1,74 @@
import JCodeKit
import SwiftUI
/// Collapsible tool call card with live status.
struct ToolCallCard: View {
let call: TranscriptEntry.ToolCall
@State private var expanded = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Button {
withAnimation(.easeInOut(duration: 0.15)) {
expanded.toggle()
}
} label: {
HStack(spacing: 8) {
statusIcon
Text(call.name)
.font(Theme.mono(13, weight: .medium))
.foregroundStyle(Theme.textPrimary)
Spacer()
Image(systemName: expanded ? "chevron.up" : "chevron.down")
.font(.caption2)
.foregroundStyle(Theme.textTertiary)
}
}
if expanded {
if !call.input.isEmpty {
codeBlock(call.input)
}
if !call.output.isEmpty {
codeBlock(String(call.output.prefix(2000)))
}
if case let .failed(message) = call.status {
Text(message)
.font(Theme.mono(12))
.foregroundStyle(Theme.error)
}
}
}
.padding(8)
.background(Theme.surfaceElevated)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
@ViewBuilder
private var statusIcon: some View {
switch call.status {
case .streamingInput, .running:
ProgressView()
.controlSize(.mini)
.tint(Theme.mint)
case .succeeded:
Image(systemName: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(Theme.mint)
case .failed:
Image(systemName: "xmark.circle.fill")
.font(.caption)
.foregroundStyle(Theme.error)
}
}
private func codeBlock(_ text: String) -> some View {
ScrollView(.horizontal, showsIndicators: false) {
Text(text)
.font(Theme.mono(11))
.foregroundStyle(Theme.textSecondary)
.padding(8)
}
.background(Theme.background)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -0,0 +1,135 @@
import JCodeKit
import SwiftUI
/// Scrolling transcript with pinned-to-bottom auto-follow.
///
/// Auto-scroll only engages while the user is at (or near) the bottom, so
/// scrolling up to read history is never hijacked by streaming output. A
/// jump-to-latest button appears whenever the view is unpinned. Short threads
/// are anchored to the bottom (chat convention); once the content exceeds the
/// viewport it scrolls normally. An empty session shows a centered placeholder.
struct TranscriptView: View {
let entries: [TranscriptEntry]
let isReasoning: Bool
/// True while the viewport is at (or near) the bottom of the content.
@State private var isPinnedToBottom = true
/// Distance from the bottom below which the view counts as pinned.
private static let pinThreshold: CGFloat = 56
var body: some View {
if entries.isEmpty && !isReasoning {
EmptyTranscript()
} else {
GeometryReader { viewport in
scroller(viewportHeight: viewport.size.height)
}
}
}
private func scroller(viewportHeight: CGFloat) -> some View {
ScrollViewReader { proxy in
ScrollView {
// A flexible top spacer pushes short content to the bottom of
// the viewport; it collapses to zero once content overflows.
LazyVStack(alignment: .leading, spacing: 16) {
Spacer(minLength: 0)
ForEach(entries) { entry in
EntryView(entry: entry)
.id(entry.id)
}
if isReasoning {
thinkingRow
}
Color.clear.frame(height: 1).id("bottom")
}
.frame(minHeight: max(0, viewportHeight - 16), alignment: .bottom)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(
GeometryReader { content in
Color.clear.preference(
key: BottomDistanceKey.self,
value: content.frame(in: .named("transcript")).maxY
- viewportHeight
)
}
)
}
.coordinateSpace(name: "transcript")
.scrollDismissesKeyboard(.interactively)
.onPreferenceChange(BottomDistanceKey.self) { distance in
MainActor.assumeIsolated {
let pinned = distance < Self.pinThreshold
if pinned != isPinnedToBottom {
isPinnedToBottom = pinned
}
}
}
.onChange(of: entries.last?.text) {
guard isPinnedToBottom else { return }
proxy.scrollTo("bottom", anchor: .bottom)
}
.onChange(of: entries.count) {
// Follow new entries when pinned; always follow the user's
// own sends so their message never lands off-screen.
if isPinnedToBottom || entries.last?.role == .user {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
.overlay(alignment: .bottomTrailing) {
if !isPinnedToBottom {
ScrollToBottomButton {
withAnimation(.easeOut(duration: 0.15)) {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
.padding(.trailing, 16)
.padding(.bottom, 8)
}
}
}
}
private var thinkingRow: some View {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
.tint(Theme.textTertiary)
Text("thinking")
.font(Theme.mono(12))
.foregroundStyle(Theme.textTertiary)
}
.padding(.leading, 4)
}
}
/// How far the content's bottom edge sits below the viewport's bottom edge.
private struct BottomDistanceKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
/// Floating jump-to-latest affordance shown while scrolled up.
private struct ScrollToBottomButton: View {
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: "arrow.down")
.font(.body.weight(.semibold))
.foregroundStyle(Theme.textPrimary)
.frame(width: 44, height: 44)
.background(Theme.surfaceElevated)
.clipShape(Circle())
.overlay(Circle().stroke(Theme.border, lineWidth: 1))
}
.accessibilityLabel("Scroll to bottom")
.accessibilityHint("Jumps to the latest message")
.transition(.opacity)
}
}