Files
1jehuang--jcode/ios/Sources/JCodeMobile/Views/ChatView.swift
T
wehub-resource-sync a789495a98
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
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

126 lines
4.1 KiB
Swift

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)
}
}