diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift index dedf34b..2f37ada 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -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. diff --git a/scarf/scarf/Features/Chat/ViewModels/RichChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/RichChatViewModel.swift index 0728dbd..6718bd5 100644 --- a/scarf/scarf/Features/Chat/ViewModels/RichChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/RichChatViewModel.swift @@ -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) { diff --git a/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift b/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift index 76f04f9..5590884 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift @@ -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) diff --git a/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift b/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift index 41ec060..0f33cd5 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift @@ -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 { diff --git a/scarf/scarf/Features/Chat/Views/RichChatView.swift b/scarf/scarf/Features/Chat/Views/RichChatView.swift index 0cfd44b..5fd5861 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatView.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatView.swift @@ -28,6 +28,7 @@ struct RichChatView: View { RichChatMessageList( groups: richChat.messageGroups, isWorking: richChat.isAgentWorking, + isLoadingSession: chatViewModel.isPreparingSession, scrollTrigger: richChat.scrollTrigger ) diff --git a/scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift b/scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift index 467a934..915f9ad 100644 --- a/scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift +++ b/scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift @@ -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