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:
Alan Wizemann
2026-04-20 17:15:46 -07:00
parent a68e0c5f42
commit c8208dedb1
6 changed files with 75 additions and 19 deletions
@@ -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