fix: polish web chat empty/error state
This commit is contained in:
@@ -41,7 +41,7 @@
|
||||
- Android Chat UI: use `onPrimary` for user bubble text to preserve contrast (thanks @Syhids).
|
||||
- Control UI: sync sidebar navigation with the URL for deep-linking, and auto-scroll chat to the latest message.
|
||||
- Control UI: disable Web Chat + Talk when no iOS/Android node is connected; refreshed Web Chat styling and keyboard send.
|
||||
- macOS Web Chat: fix composer layout so the connection pill and send button stay inside the input field.
|
||||
- macOS Web Chat: improve empty/error states, focus message field on open, and keep pill/send inside the input field.
|
||||
- macOS: bundle Control UI assets into the app relay so the packaged app can serve them (thanks @mbelinky).
|
||||
- Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android).
|
||||
- iOS Talk Mode: fix chat completion wait to time out even if no events arrive (prevents “Thinking…” hangs).
|
||||
|
||||
@@ -15,6 +15,8 @@ struct ClawdisChatComposer: View {
|
||||
#if !os(macOS)
|
||||
@State private var pickerItems: [PhotosPickerItem] = []
|
||||
@FocusState private var isFocused: Bool
|
||||
#else
|
||||
@State private var shouldFocusTextView = false
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
@@ -33,13 +35,6 @@ struct ClawdisChatComposer: View {
|
||||
}
|
||||
|
||||
self.editor
|
||||
|
||||
if let error = self.viewModel.errorText, !error.isEmpty {
|
||||
Text(error)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.padding(self.composerPadding)
|
||||
.background(
|
||||
@@ -53,6 +48,9 @@ struct ClawdisChatComposer: View {
|
||||
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
|
||||
self.handleDrop(providers)
|
||||
}
|
||||
.onAppear {
|
||||
self.shouldFocusTextView = true
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -185,7 +183,7 @@ struct ClawdisChatComposer: View {
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
ChatComposerTextView(text: self.$viewModel.input) {
|
||||
ChatComposerTextView(text: self.$viewModel.input, shouldFocus: self.$shouldFocusTextView) {
|
||||
self.viewModel.send()
|
||||
}
|
||||
.frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight)
|
||||
@@ -336,6 +334,7 @@ import UniformTypeIdentifiers
|
||||
|
||||
private struct ChatComposerTextView: NSViewRepresentable {
|
||||
@Binding var text: String
|
||||
@Binding var shouldFocus: Bool
|
||||
var onSend: () -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||||
@@ -382,6 +381,12 @@ private struct ChatComposerTextView: NSViewRepresentable {
|
||||
|
||||
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
||||
guard let textView = scrollView.documentView as? ChatComposerNSTextView else { return }
|
||||
|
||||
if self.shouldFocus, let window = scrollView.window {
|
||||
window.makeFirstResponder(textView)
|
||||
self.shouldFocus = false
|
||||
}
|
||||
|
||||
let isEditing = scrollView.window?.firstResponder == textView
|
||||
if isEditing { return }
|
||||
|
||||
|
||||
@@ -92,6 +92,8 @@ public struct ClawdisChatView: View {
|
||||
.controlSize(.large)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
self.messageListOverlay
|
||||
}
|
||||
// Ensure the message list claims vertical space on the first layout pass.
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
@@ -160,6 +162,112 @@ public struct ClawdisChatView: View {
|
||||
return self.mergeToolResults(in: base)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var messageListOverlay: some View {
|
||||
if self.viewModel.isLoading {
|
||||
EmptyView()
|
||||
} else if let error = self.activeErrorText {
|
||||
let presentation = self.errorPresentation(for: error)
|
||||
if self.hasVisibleMessageListContent {
|
||||
VStack(spacing: 0) {
|
||||
ChatNoticeBanner(
|
||||
systemImage: presentation.systemImage,
|
||||
title: presentation.title,
|
||||
message: error,
|
||||
tint: presentation.tint,
|
||||
dismiss: { self.viewModel.errorText = nil },
|
||||
refresh: { self.viewModel.refresh() })
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.top, 8)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
} else {
|
||||
ChatNoticeCard(
|
||||
systemImage: presentation.systemImage,
|
||||
title: presentation.title,
|
||||
message: error,
|
||||
tint: presentation.tint,
|
||||
actionTitle: "Refresh",
|
||||
action: { self.viewModel.refresh() })
|
||||
.padding(.horizontal, 24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
} else if self.showsEmptyState {
|
||||
ChatNoticeCard(
|
||||
systemImage: "bubble.left.and.bubble.right.fill",
|
||||
title: self.emptyStateTitle,
|
||||
message: self.emptyStateMessage,
|
||||
tint: .accentColor,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
.padding(.horizontal, 24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private var activeErrorText: String? {
|
||||
guard let text = self.viewModel.errorText?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private var hasVisibleMessageListContent: Bool {
|
||||
if !self.visibleMessages.isEmpty {
|
||||
return true
|
||||
}
|
||||
if let text = self.viewModel.streamingAssistantText,
|
||||
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
return true
|
||||
}
|
||||
if self.viewModel.pendingRunCount > 0 {
|
||||
return true
|
||||
}
|
||||
if !self.viewModel.pendingToolCalls.isEmpty {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private var showsEmptyState: Bool {
|
||||
self.viewModel.messages.isEmpty &&
|
||||
(self.viewModel.streamingAssistantText?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) &&
|
||||
self.viewModel.pendingRunCount == 0 &&
|
||||
self.viewModel.pendingToolCalls.isEmpty
|
||||
}
|
||||
|
||||
private var emptyStateTitle: String {
|
||||
#if os(macOS)
|
||||
"Web Chat"
|
||||
#else
|
||||
"Chat"
|
||||
#endif
|
||||
}
|
||||
|
||||
private var emptyStateMessage: String {
|
||||
#if os(macOS)
|
||||
"Type a message below to start.\nReturn sends • Shift-Return adds a line break."
|
||||
#else
|
||||
"Type a message below to start."
|
||||
#endif
|
||||
}
|
||||
|
||||
private func errorPresentation(for error: String) -> (title: String, systemImage: String, tint: Color) {
|
||||
let lower = error.lowercased()
|
||||
if lower.contains("not connected") || lower.contains("socket") {
|
||||
return ("Disconnected", "wifi.slash", .orange)
|
||||
}
|
||||
if lower.contains("timed out") {
|
||||
return ("Timed out", "clock.badge.exclamationmark", .orange)
|
||||
}
|
||||
return ("Error", "exclamationmark.triangle.fill", .orange)
|
||||
}
|
||||
|
||||
private func mergeToolResults(in messages: [ClawdisChatMessage]) -> [ClawdisChatMessage] {
|
||||
var result: [ClawdisChatMessage] = []
|
||||
result.reserveCapacity(messages.count)
|
||||
@@ -243,3 +351,101 @@ public struct ClawdisChatView: View {
|
||||
return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatNoticeCard: View {
|
||||
let systemImage: String
|
||||
let title: String
|
||||
let message: String
|
||||
let tint: Color
|
||||
let actionTitle: String?
|
||||
let action: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(self.tint.opacity(0.16))
|
||||
Image(systemName: self.systemImage)
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(self.tint)
|
||||
}
|
||||
.frame(width: 52, height: 52)
|
||||
|
||||
Text(self.title)
|
||||
.font(.headline)
|
||||
|
||||
Text(self.message)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(4)
|
||||
.frame(maxWidth: 360)
|
||||
|
||||
if let actionTitle, let action {
|
||||
Button(actionTitle, action: action)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(ClawdisChatTheme.subtleCard)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)))
|
||||
.shadow(color: .black.opacity(0.14), radius: 18, y: 8)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatNoticeBanner: View {
|
||||
let systemImage: String
|
||||
let title: String
|
||||
let message: String
|
||||
let tint: Color
|
||||
let dismiss: () -> Void
|
||||
let refresh: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: self.systemImage)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(self.tint)
|
||||
.padding(.top, 1)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
|
||||
Text(self.message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Button(action: self.refresh) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.help("Refresh")
|
||||
|
||||
Button(action: self.dismiss) {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.help("Dismiss")
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(ClawdisChatTheme.subtleCard)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user