feat(chat): hideable sessions + inspector panes for the Mac chat (#58)

The 3-pane layout (264px sessions list + transcript + 320px inspector)
ate ~584px of horizontal space on every chat window — squeezing the
actual transcript on smaller windows AND keeping the "No tool selected"
empty-state visible even when irrelevant. User reported that as
"reasoning, in/out, hard to read because of the tool selected box
taking so much space".

Add toolbar toggles + Settings parity to hide either side pane:

- Two new @AppStorage keys in ChatDensitySettings:
    scarf.chat.showSessionsList (default true)
    scarf.chat.showInspector    (default true)
- ChatView toolbar gains two buttons next to the View picker:
  sidebar.left toggles the sessions list, sidebar.right toggles the
  inspector. Both highlight in accent color when visible. Hidden when
  in terminal mode (the 3-pane layout doesn't apply there).
- RichChatView body conditionally renders each side pane and its
  divider, with .transition(.move + .opacity) and a 180ms easeInOut
  animation so the transcript reflows smoothly rather than snapping.
- Auto-show inspector when a tool card is focused so a click never
  silently dies — onChange of focusedToolCallId flips
  showInspector back on if it was off. The slide-in animation
  covers the visual transition.
- DisplayTab → Chat density gains parity Toggle rows for "Sessions
  list" and "Tool inspector" — same group as the existing density
  pickers from #47/#48 so the settings home is consistent.

Defaults match today's behavior so existing users see no change
until they opt out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-29 13:04:55 +02:00
parent 424711c3d9
commit 61e61f556a
4 changed files with 109 additions and 6 deletions
@@ -15,6 +15,13 @@ enum ChatDensityKeys {
static let toolCardStyle = "scarf.chat.toolCardStyle" static let toolCardStyle = "scarf.chat.toolCardStyle"
static let reasoningStyle = "scarf.chat.reasoningStyle" static let reasoningStyle = "scarf.chat.reasoningStyle"
static let fontScale = "scarf.chat.fontScale" static let fontScale = "scarf.chat.fontScale"
/// Whether the left sessions list pane is visible in the Mac
/// 3-pane chat layout. Defaults true (today's behavior). Issue #58.
static let showSessionsList = "scarf.chat.showSessionsList"
/// Whether the right tool inspector pane is visible. Defaults true.
/// When hidden, clicking a tool card auto-flips it back on so the
/// click does what the user expects (`ToolCallCard.onFocus`). Issue #58.
static let showInspector = "scarf.chat.showInspector"
} }
/// How `RichMessageBubble` renders the per-call tool widgets. /// How `RichMessageBubble` renders the per-call tool widgets.
@@ -7,6 +7,15 @@ struct ChatView: View {
@Environment(AppCoordinator.self) private var coordinator @Environment(AppCoordinator.self) private var coordinator
@State private var showErrorDetails = false @State private var showErrorDetails = false
/// Side-pane visibility toggles (issue #58). Drive the new
/// sidebar.left / sidebar.right toolbar buttons; `RichChatView.body`
/// reads the same `@AppStorage` keys and conditionally renders the
/// panes with a slide animation.
@AppStorage(ChatDensityKeys.showSessionsList)
private var showSessionsList: Bool = true
@AppStorage(ChatDensityKeys.showInspector)
private var showInspector: Bool = true
var body: some View { var body: some View {
@Bindable var vm = viewModel @Bindable var vm = viewModel
@Bindable var coord = coordinator @Bindable var coord = coordinator
@@ -225,6 +234,30 @@ struct ChatView: View {
voiceControls voiceControls
} }
// Side-pane toggles (issue #58). Only meaningful in rich-chat
// mode where the 3-pane layout exists; terminal mode is a
// single SwiftTerm view and these would do nothing. Hide
// them on the terminal side so the toolbar stays uncluttered.
if viewModel.displayMode == .richChat {
Button {
showSessionsList.toggle()
} label: {
Image(systemName: "sidebar.left")
.foregroundStyle(showSessionsList ? Color.accentColor : .secondary)
}
.buttonStyle(.borderless)
.help(showSessionsList ? "Hide sessions list" : "Show sessions list")
Button {
showInspector.toggle()
} label: {
Image(systemName: "sidebar.right")
.foregroundStyle(showInspector ? Color.accentColor : .secondary)
}
.buttonStyle(.borderless)
.help(showInspector ? "Hide tool inspector" : "Show tool inspector")
}
Picker("View", selection: Bindable(viewModel).displayMode) { Picker("View", selection: Bindable(viewModel).displayMode) {
Image(systemName: "terminal") Image(systemName: "terminal")
.help("Terminal") .help("Terminal")
@@ -386,6 +419,23 @@ struct ChatView: View {
} }
) )
} }
// Model preflight open before any ACP plumbing when the active
// server has no `model.default` / `model.provider` set. Keeps the
// user from typing a prompt only to find out the upstream
// provider rejected it.
.sheet(isPresented: modelPreflightBinding) {
ChatModelPreflightSheet(
reason: viewModel.modelPreflightReason ?? "",
serverDisplayName: viewModel.context.displayName,
onSelect: { model, provider in
viewModel.confirmModelPreflight(model: model, provider: provider)
},
onCancel: {
viewModel.cancelModelPreflight()
}
)
.environment(\.serverContext, viewModel.context)
}
} }
private var permissionBinding: Binding<RichChatViewModel.PendingPermission?> { private var permissionBinding: Binding<RichChatViewModel.PendingPermission?> {
@@ -394,6 +444,15 @@ struct ChatView: View {
set: { viewModel.richChatViewModel.pendingPermission = $0 } set: { viewModel.richChatViewModel.pendingPermission = $0 }
) )
} }
private var modelPreflightBinding: Binding<Bool> {
Binding(
get: { viewModel.modelPreflightReason != nil },
set: { newValue in
if !newValue { viewModel.cancelModelPreflight() }
}
)
}
} }
// MARK: - Permission Approval View // MARK: - Permission Approval View
@@ -29,14 +29,25 @@ struct RichChatView: View {
@AppStorage(ChatDensityKeys.fontScale) @AppStorage(ChatDensityKeys.fontScale)
private var fontScale: Double = ChatFontScale.default private var fontScale: Double = ChatFontScale.default
/// Sessions-list / inspector pane visibility (issue #58). Defaults
/// `true` so existing users see no change until they opt out via
/// the toolbar buttons or Settings Display Chat density.
@AppStorage(ChatDensityKeys.showSessionsList)
private var showSessionsList: Bool = true
@AppStorage(ChatDensityKeys.showInspector)
private var showInspector: Bool = true
/// In ACP mode, events drive updates directly no DB polling needed. /// In ACP mode, events drive updates directly no DB polling needed.
private var isACPMode: Bool { chatViewModel.isACPConnected } private var isACPMode: Bool { chatViewModel.isACPConnected }
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
ChatSessionListPane(chatViewModel: chatViewModel, richChat: richChat) if showSessionsList {
.frame(width: 264) ChatSessionListPane(chatViewModel: chatViewModel, richChat: richChat)
Divider().background(ScarfColor.border) .frame(width: 264)
.transition(.move(edge: .leading).combined(with: .opacity))
Divider().background(ScarfColor.border)
}
ChatTranscriptPane( ChatTranscriptPane(
richChat: richChat, richChat: richChat,
chatViewModel: chatViewModel, chatViewModel: chatViewModel,
@@ -44,12 +55,30 @@ struct RichChatView: View {
isEnabled: isEnabled isEnabled: isEnabled
) )
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
Divider().background(ScarfColor.border) if showInspector {
ChatInspectorPane(chatViewModel: chatViewModel) Divider().background(ScarfColor.border)
.frame(width: 320) ChatInspectorPane(chatViewModel: chatViewModel)
.frame(width: 320)
.transition(.move(edge: .trailing).combined(with: .opacity))
}
} }
.frame(minHeight: 0, idealHeight: 500, maxHeight: .infinity) .frame(minHeight: 0, idealHeight: 500, maxHeight: .infinity)
.environment(\.dynamicTypeSize, ChatFontScale.dynamicTypeSize(for: fontScale)) .environment(\.dynamicTypeSize, ChatFontScale.dynamicTypeSize(for: fontScale))
// Animate side-pane shows/hides so the transcript reflows
// smoothly rather than snapping. ~180ms feels responsive
// without being jarring.
.animation(.easeInOut(duration: 0.18), value: showSessionsList)
.animation(.easeInOut(duration: 0.18), value: showInspector)
// Auto-show inspector when a tool call is focused so a click
// on a tool card is never silently lost (issue #58 follow-up).
// Tool clicks set `chatViewModel.focusedToolCallId`; if that
// becomes non-nil while the inspector is hidden, flip it back
// on. The animation modifiers above cover the slide-in.
.onChange(of: chatViewModel.focusedToolCallId) { _, new in
if new != nil, !showInspector {
showInspector = true
}
}
// DB polling fallback for terminal mode only never overwrite ACP messages // DB polling fallback for terminal mode only never overwrite ACP messages
.onChange(of: fileWatcher.lastChangeDate) { .onChange(of: fileWatcher.lastChangeDate) {
if !isACPMode, !richChat.hasMessages, richChat.sessionId != nil { if !isACPMode, !richChat.hasMessages, richChat.sessionId != nil {
@@ -16,6 +16,12 @@ struct DisplayTab: View {
private var reasoningStyle: String = ReasoningStyle.disclosure.rawValue private var reasoningStyle: String = ReasoningStyle.disclosure.rawValue
@AppStorage(ChatDensityKeys.fontScale) @AppStorage(ChatDensityKeys.fontScale)
private var fontScale: Double = ChatFontScale.default private var fontScale: Double = ChatFontScale.default
/// Side-pane visibility (issue #58). Mirrors the toolbar buttons in
/// ChatView; this is the canonical preferences home.
@AppStorage(ChatDensityKeys.showSessionsList)
private var showSessionsList: Bool = true
@AppStorage(ChatDensityKeys.showInspector)
private var showInspector: Bool = true
var body: some View { var body: some View {
SettingsSection(title: "Chat density", icon: "rectangle.compress.vertical") { SettingsSection(title: "Chat density", icon: "rectangle.compress.vertical") {
@@ -30,6 +36,8 @@ struct DisplayTab: View {
options: ReasoningStyle.allCases.map { ($0.rawValue, $0.displayName) } options: ReasoningStyle.allCases.map { ($0.rawValue, $0.displayName) }
) )
FontScaleRow(scale: $fontScale) FontScaleRow(scale: $fontScale)
ToggleRow(label: "Sessions list", isOn: showSessionsList) { showSessionsList = $0 }
ToggleRow(label: "Tool inspector", isOn: showInspector) { showInspector = $0 }
DensityFootnote() DensityFootnote()
} }