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
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user