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
|
private var isHandlingDisconnect = false
|
||||||
var isACPConnected: Bool { acpClient != nil && hasActiveProcess }
|
var isACPConnected: Bool { acpClient != nil && hasActiveProcess }
|
||||||
var acpStatus: String = ""
|
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?
|
var acpError: String?
|
||||||
/// Human-readable hint derived from error + stderr (e.g. "set ANTHROPIC_API_KEY").
|
/// Human-readable hint derived from error + stderr (e.g. "set ANTHROPIC_API_KEY").
|
||||||
/// Shown above the raw error in the UI when present.
|
/// Shown above the raw error in the UI when present.
|
||||||
|
|||||||
@@ -170,6 +170,11 @@ final class RichChatViewModel {
|
|||||||
streamingThinkingText = ""
|
streamingThinkingText = ""
|
||||||
streamingToolCalls = []
|
streamingToolCalls = []
|
||||||
buildMessageGroups()
|
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.
|
/// Process a streaming ACP event and update the message list.
|
||||||
@@ -337,6 +342,10 @@ final class RichChatViewModel {
|
|||||||
acpCachedReadTokens += response.cachedReadTokens
|
acpCachedReadTokens += response.cachedReadTokens
|
||||||
isAgentWorking = false
|
isAgentWorking = false
|
||||||
buildMessageGroups()
|
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) {
|
private func handleConnectionLost(reason: String) {
|
||||||
|
|||||||
@@ -17,11 +17,12 @@ struct RichChatInputBar: View {
|
|||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
if showMenu {
|
if showMenu {
|
||||||
SlashCommandMenu(
|
SlashCommandMenu(
|
||||||
commands: commands,
|
commands: filteredCommands,
|
||||||
query: menuQuery,
|
agentHasCommands: !commands.isEmpty,
|
||||||
selectedIndex: $selectedIndex,
|
selectedIndex: $selectedIndex,
|
||||||
onSelect: insertCommand
|
onSelect: insertCommand
|
||||||
)
|
)
|
||||||
|
.id(menuQuery)
|
||||||
.background(.regularMaterial)
|
.background(.regularMaterial)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 10)
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import SwiftUI
|
|||||||
struct RichChatMessageList: View {
|
struct RichChatMessageList: View {
|
||||||
let groups: [MessageGroup]
|
let groups: [MessageGroup]
|
||||||
let isWorking: Bool
|
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").
|
/// External trigger to force a scroll-to-bottom (e.g., from "Return to Active Session").
|
||||||
var scrollTrigger: UUID = UUID()
|
var scrollTrigger: UUID = UUID()
|
||||||
|
|
||||||
@@ -28,7 +32,23 @@ struct RichChatMessageList: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
if groups.isEmpty && !isWorking {
|
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
|
ForEach(groups) { group in
|
||||||
@@ -42,6 +62,8 @@ struct RichChatMessageList: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
.animation(.easeInOut(duration: 0.15), value: isLoadingSession)
|
||||||
|
.animation(.easeInOut(duration: 0.15), value: groups.isEmpty)
|
||||||
}
|
}
|
||||||
.defaultScrollAnchor(.bottom)
|
.defaultScrollAnchor(.bottom)
|
||||||
.onChange(of: scrollTrigger) {
|
.onChange(of: scrollTrigger) {
|
||||||
@@ -75,8 +97,16 @@ struct RichChatMessageList: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.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 {
|
private var typingIndicator: some View {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ struct RichChatView: View {
|
|||||||
RichChatMessageList(
|
RichChatMessageList(
|
||||||
groups: richChat.messageGroups,
|
groups: richChat.messageGroups,
|
||||||
isWorking: richChat.isAgentWorking,
|
isWorking: richChat.isAgentWorking,
|
||||||
|
isLoadingSession: chatViewModel.isPreparingSession,
|
||||||
scrollTrigger: richChat.scrollTrigger
|
scrollTrigger: richChat.scrollTrigger
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,27 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Floating menu of available slash commands shown above the chat input when
|
/// Floating menu of available slash commands shown above the chat input when
|
||||||
/// the user types `/` as the first character. Read-only list — the parent
|
/// the user types `/` as the first character. Purely presentational — the
|
||||||
/// owns selection state and insertion.
|
/// parent filters the list and owns selection state.
|
||||||
struct SlashCommandMenu: View {
|
struct SlashCommandMenu: View {
|
||||||
|
/// Pre-filtered commands to display.
|
||||||
let commands: [HermesSlashCommand]
|
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
|
@Binding var selectedIndex: Int
|
||||||
var onSelect: (HermesSlashCommand) -> Void
|
var onSelect: (HermesSlashCommand) -> Void
|
||||||
|
|
||||||
var filtered: [HermesSlashCommand] {
|
/// Case-insensitive prefix match on the command name. Exposed as a static
|
||||||
Self.filter(commands: commands, query: query)
|
/// helper so the parent can share filter logic with its key handlers.
|
||||||
}
|
|
||||||
|
|
||||||
static func filter(commands: [HermesSlashCommand], query: String) -> [HermesSlashCommand] {
|
static func filter(commands: [HermesSlashCommand], query: String) -> [HermesSlashCommand] {
|
||||||
let q = query.lowercased()
|
let q = query.lowercased()
|
||||||
if q.isEmpty { return commands }
|
if q.isEmpty { return commands }
|
||||||
let prefix = commands.filter { $0.name.lowercased().hasPrefix(q) }
|
return commands.filter { $0.name.lowercased().hasPrefix(q) }
|
||||||
if !prefix.isEmpty { return prefix }
|
|
||||||
return commands.filter { $0.description.lowercased().contains(q) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let items = filtered
|
if !agentHasCommands {
|
||||||
if commands.isEmpty {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("No commands available")
|
Text("No commands available")
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
@@ -34,7 +32,7 @@ struct SlashCommandMenu: View {
|
|||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.frame(minWidth: 360, alignment: .leading)
|
.frame(minWidth: 360, alignment: .leading)
|
||||||
} else if items.isEmpty {
|
} else if commands.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("No matching commands")
|
Text("No matching commands")
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
@@ -49,7 +47,7 @@ struct SlashCommandMenu: View {
|
|||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
ForEach(Array(items.enumerated()), id: \.element.id) { index, command in
|
ForEach(Array(commands.enumerated()), id: \.element.id) { index, command in
|
||||||
SlashCommandRow(
|
SlashCommandRow(
|
||||||
command: command,
|
command: command,
|
||||||
isSelected: index == selectedIndex
|
isSelected: index == selectedIndex
|
||||||
|
|||||||
Reference in New Issue
Block a user