mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
fix(chat): slash-menu filter, auto-scroll on send/complete, loading state
- Slash menu: filter at the parent and pass the pre-filtered list to SlashCommandMenu (pure-prefix match, no description fallback). Adds `.id(menuQuery)` to force a fresh view on every query so SwiftUI can't render stale props — this was the cause of "typing /mo still shows /help" (the old description fallback plus a cached child view kept /help pinned regardless of query). - Auto-scroll to bottom when the user submits a message and again when the prompt completes. `.defaultScrollAnchor(.bottom)` handles slow streaming fine, but rapid slash-command responses outran the anchor and left the response off-screen. - Loading state: add `ChatViewModel.isPreparingSession` (true during Starting / Creating / Loading / Reconnecting). While true, the message list swaps its placeholder for a ProgressView — non-blocking, just a view inside the ScrollView. - Center the empty-state placeholder properly: replace `.padding(.vertical, 80)` with Spacers inside `.containerRelativeFrame(.vertical)` so the placeholder sits in the true vertical center of the chat pane at any window size. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,23 @@ final class ChatViewModel {
|
||||
private var isHandlingDisconnect = false
|
||||
var isACPConnected: Bool { acpClient != nil && hasActiveProcess }
|
||||
var acpStatus: String = ""
|
||||
|
||||
/// True while a session is being established or restored — from the user
|
||||
/// kicking off "start chat" or "resume session" until the ACP session is
|
||||
/// ready for messages. The chat pane uses this to show a loader in place
|
||||
/// of the empty-state placeholder.
|
||||
var isPreparingSession: Bool {
|
||||
guard hasActiveProcess else { return false }
|
||||
switch acpStatus {
|
||||
case "Starting...",
|
||||
"Creating session...",
|
||||
"Creating new session...",
|
||||
"Loading session...":
|
||||
return true
|
||||
default:
|
||||
return acpStatus.hasPrefix("Reconnecting")
|
||||
}
|
||||
}
|
||||
var acpError: String?
|
||||
/// Human-readable hint derived from error + stderr (e.g. "set ANTHROPIC_API_KEY").
|
||||
/// Shown above the raw error in the UI when present.
|
||||
|
||||
@@ -170,6 +170,11 @@ final class RichChatViewModel {
|
||||
streamingThinkingText = ""
|
||||
streamingToolCalls = []
|
||||
buildMessageGroups()
|
||||
// User just submitted — jump to the bottom so they see their message
|
||||
// and the incoming response. `.defaultScrollAnchor(.bottom)` handles
|
||||
// slow streaming fine, but rapid responses (slash commands especially)
|
||||
// arrive faster than the anchor can track.
|
||||
requestScrollToBottom()
|
||||
}
|
||||
|
||||
/// Process a streaming ACP event and update the message list.
|
||||
@@ -337,6 +342,10 @@ final class RichChatViewModel {
|
||||
acpCachedReadTokens += response.cachedReadTokens
|
||||
isAgentWorking = false
|
||||
buildMessageGroups()
|
||||
// Final position after the prompt settles. Catches fast responses
|
||||
// (slash commands, short replies) where `.defaultScrollAnchor(.bottom)`
|
||||
// didn't quite track the abrupt content growth.
|
||||
requestScrollToBottom()
|
||||
}
|
||||
|
||||
private func handleConnectionLost(reason: String) {
|
||||
|
||||
@@ -17,11 +17,12 @@ struct RichChatInputBar: View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if showMenu {
|
||||
SlashCommandMenu(
|
||||
commands: commands,
|
||||
query: menuQuery,
|
||||
commands: filteredCommands,
|
||||
agentHasCommands: !commands.isEmpty,
|
||||
selectedIndex: $selectedIndex,
|
||||
onSelect: insertCommand
|
||||
)
|
||||
.id(menuQuery)
|
||||
.background(.regularMaterial)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
|
||||
@@ -3,6 +3,10 @@ import SwiftUI
|
||||
struct RichChatMessageList: View {
|
||||
let groups: [MessageGroup]
|
||||
let isWorking: Bool
|
||||
/// True while the ACP session is being established or restored — used to
|
||||
/// swap the empty-state placeholder for a progress indicator so the user
|
||||
/// knows something is happening while history loads.
|
||||
var isLoadingSession: Bool = false
|
||||
/// External trigger to force a scroll-to-bottom (e.g., from "Return to Active Session").
|
||||
var scrollTrigger: UUID = UUID()
|
||||
|
||||
@@ -28,7 +32,23 @@ struct RichChatMessageList: View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if groups.isEmpty && !isWorking {
|
||||
emptyState
|
||||
// Fill the scroll view's visible height so Spacers
|
||||
// can vertically center the placeholder. Previously
|
||||
// `.padding(.vertical, 80)` left the placeholder
|
||||
// floating at whatever y-offset `.defaultScrollAnchor(.bottom)`
|
||||
// settled on — usually near the bottom of the pane.
|
||||
VStack {
|
||||
Spacer(minLength: 0)
|
||||
if isLoadingSession {
|
||||
loadingState
|
||||
} else {
|
||||
emptyState
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.containerRelativeFrame(.vertical)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
ForEach(groups) { group in
|
||||
@@ -42,6 +62,8 @@ struct RichChatMessageList: View {
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.animation(.easeInOut(duration: 0.15), value: isLoadingSession)
|
||||
.animation(.easeInOut(duration: 0.15), value: groups.isEmpty)
|
||||
}
|
||||
.defaultScrollAnchor(.bottom)
|
||||
.onChange(of: scrollTrigger) {
|
||||
@@ -75,8 +97,16 @@ struct RichChatMessageList: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 80)
|
||||
}
|
||||
|
||||
private var loadingState: some View {
|
||||
VStack(spacing: 14) {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
Text("Loading session…")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var typingIndicator: some View {
|
||||
|
||||
@@ -28,6 +28,7 @@ struct RichChatView: View {
|
||||
RichChatMessageList(
|
||||
groups: richChat.messageGroups,
|
||||
isWorking: richChat.isAgentWorking,
|
||||
isLoadingSession: chatViewModel.isPreparingSession,
|
||||
scrollTrigger: richChat.scrollTrigger
|
||||
)
|
||||
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Floating menu of available slash commands shown above the chat input when
|
||||
/// the user types `/` as the first character. Read-only list — the parent
|
||||
/// owns selection state and insertion.
|
||||
/// the user types `/` as the first character. Purely presentational — the
|
||||
/// parent filters the list and owns selection state.
|
||||
struct SlashCommandMenu: View {
|
||||
/// Pre-filtered commands to display.
|
||||
let commands: [HermesSlashCommand]
|
||||
let query: String
|
||||
/// Whether the agent advertised any commands at all. Lets us distinguish
|
||||
/// "agent hasn't sent commands yet" from "filter matched nothing".
|
||||
let agentHasCommands: Bool
|
||||
@Binding var selectedIndex: Int
|
||||
var onSelect: (HermesSlashCommand) -> Void
|
||||
|
||||
var filtered: [HermesSlashCommand] {
|
||||
Self.filter(commands: commands, query: query)
|
||||
}
|
||||
|
||||
/// Case-insensitive prefix match on the command name. Exposed as a static
|
||||
/// helper so the parent can share filter logic with its key handlers.
|
||||
static func filter(commands: [HermesSlashCommand], query: String) -> [HermesSlashCommand] {
|
||||
let q = query.lowercased()
|
||||
if q.isEmpty { return commands }
|
||||
let prefix = commands.filter { $0.name.lowercased().hasPrefix(q) }
|
||||
if !prefix.isEmpty { return prefix }
|
||||
return commands.filter { $0.description.lowercased().contains(q) }
|
||||
return commands.filter { $0.name.lowercased().hasPrefix(q) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let items = filtered
|
||||
if commands.isEmpty {
|
||||
if !agentHasCommands {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("No commands available")
|
||||
.font(.callout)
|
||||
@@ -34,7 +32,7 @@ struct SlashCommandMenu: View {
|
||||
}
|
||||
.padding(12)
|
||||
.frame(minWidth: 360, alignment: .leading)
|
||||
} else if items.isEmpty {
|
||||
} else if commands.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("No matching commands")
|
||||
.font(.callout)
|
||||
@@ -49,7 +47,7 @@ struct SlashCommandMenu: View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(items.enumerated()), id: \.element.id) { index, command in
|
||||
ForEach(Array(commands.enumerated()), id: \.element.id) { index, command in
|
||||
SlashCommandRow(
|
||||
command: command,
|
||||
isSelected: index == selectedIndex
|
||||
|
||||
Reference in New Issue
Block a user