fix(layout): cap RichChatView/ProjectSessions idealHeight; revert broken detail wrap

Prior commits tried to solve the "window grows whenever Chat or
Sessions is selected" bug by wrapping NavigationSplitView's detail
slot with an explicit frame (`205bb2c`). That broke the HSplitView
layout in Projects — the project list column, dashboard header,
tab bar, and Sessions-tab header all vanished. Scarf's convention
(PlatformsView.swift:12 calls it out explicitly) is to apply
size constraints on individual HSplitView columns, never on an
outer wrapper.

This commit:

- Reverts the broken ContentView.swift outer frame from `205bb2c`.
  NavigationSplitView.detail goes back to its v2.2.1 shape.

- Caps the subtrees whose natural ideal heights are what was
  actually pushing the window past the screen:
  - RichChatView: `.frame(minHeight: 0, idealHeight: 500, maxHeight: .infinity)`
    on the outer VStack. The message list uses a plain VStack
    (deliberately, to dodge the LazyVStack whitespace bug — see
    RichChatMessageList.swift:13-24), so its natural ideal grows
    with every message. Capping idealHeight at 500 gives the
    window a screen-safe starting size without limiting how tall
    the view can flex when the user drags the window bigger.
  - ProjectSessionsView: same treatment with `idealHeight: 400`.
    Replaces the earlier `.frame(maxWidth: .infinity, maxHeight:
    .infinity)` which set MAX but didn't influence what got
    reported upward as ideal.

- Xcode regenerated Localizable.xcstrings during builds; riding
  along.

`.frame(idealHeight:)` is the specific SwiftUI knob that overrides
a child's reported ideal on the way up — `maxHeight: .infinity`
alone doesn't. With `.windowResizability(.contentMinSize)` (still
in scarfApp, left alone), the window sizes itself to the reported
ideal on open and respects user drags above the content min. With
a screen-safe ideal, the window opens at a usable size and never
pushes past the desktop.

User-verified: window behaves correctly across section switches,
resize persists, chat input bar always visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-24 00:57:15 +02:00
parent 205bb2c56e
commit 7ad78a5492
4 changed files with 118 additions and 33 deletions
-26
View File
@@ -17,32 +17,6 @@ struct ContentView: View {
.navigationSplitViewColumnWidth(min: 180, ideal: 240, max: 360)
} detail: {
detailView
// The detail column's size is what NavigationSplitView
// reports up to the window. Without a bound here, the
// reported ideal is derived from the currently-rendered
// section's natural intrinsic size and some sections
// (Chat with a fully-materialized message list, the
// v2.3 per-project Sessions tab) have intrinsic heights
// that exceed the screen. With `.windowResizability
// (.contentMinSize)` in scarfApp, the window is forced
// at least that tall, pushing its bottom edge past the
// visible desktop and hiding the input bar.
//
// This frame pins the detail's reported ideal at a
// modest 900×600 small enough to fit any reasonable
// screen while allowing it to expand freely to
// whatever the user drags the window to. `minHeight: 0`
// is load-bearing: it overrides the "my child's min is
// huge" chain so NavigationSplitView doesn't carry a
// massive min up to the window.
.frame(
minWidth: 500,
idealWidth: 900,
maxWidth: .infinity,
minHeight: 300,
idealHeight: 600,
maxHeight: .infinity
)
.toolbar {
ToolbarItem(placement: .navigation) {
ServerSwitcherToolbar()
@@ -42,6 +42,19 @@ struct RichChatView: View {
showCompressButton: richChat.supportsCompress && !richChat.hasBroaderCommandMenu
)
}
// `idealHeight: 500` caps what this subtree REPORTS as its ideal
// height. Load-bearing: RichChatMessageList uses a plain VStack
// (not LazyVStack see RichChatMessageList.swift:13-24 for the
// rationale) inside a ScrollView, so its natural ideal grows
// with message count. Under the WindowGroup's
// `.windowResizability(.contentMinSize)` policy, that uncapped
// ideal would open the window at a height that exceeds the
// screen on long conversations, pushing the input bar below
// the visible desktop. `maxHeight: .infinity` still lets the
// view fill any larger offered space, and `minHeight: 0`
// allows it to shrink freely the ideal cap only affects the
// initial-size hint reported up to the window.
.frame(minHeight: 0, idealHeight: 500, maxHeight: .infinity)
// DB polling fallback for terminal mode only never overwrite ACP messages
.onChange(of: fileWatcher.lastChangeDate) {
if !isACPMode, !richChat.hasMessages, richChat.sessionId != nil {
@@ -20,13 +20,17 @@ struct ProjectSessionsView: View {
Divider()
content
}
// Without this clamp the inner List's intrinsic height grows
// with its row count and the enclosing VStack pushes the
// window itself past the screen. Other tabs handle this via
// their own container (widgetsTab = ScrollView, siteTab =
// explicit maxHeight) match the siteTab pattern here so
// the List scrolls internally.
.frame(maxWidth: .infinity, maxHeight: .infinity)
// `idealHeight: 400` caps what this subtree reports as its
// ideal height. Without it, the inner List's row-materialised
// intrinsic height bubbles up through NavigationSplitView's
// detail slot and, under `.windowResizability(.contentMinSize)`,
// opens the window at a height that exceeds the screen on
// busy projects the Sessions tab header + "New Chat" button
// end up below the visible desktop edge. `maxHeight: .infinity`
// still lets the List fill any taller offered space, and
// `minHeight: 0` allows it to shrink. Mirrors the same pattern
// applied in RichChatView.
.frame(minHeight: 0, idealHeight: 400, maxHeight: .infinity)
.task(id: project.id) {
// Rebuild the VM when the project changes so stale state
// from a previously-selected project doesn't bleed
+94
View File
@@ -1028,6 +1028,10 @@
"comment" : "A message that appears when a memory block is no longer present in MEMORY.md.",
"isCommentAutoGenerated" : true
},
"A project named \"%@\" already exists." : {
"comment" : "A warning message that appears in a Rename Project sheet if the user-provided name is a duplicate of an existing project. The argument is the name of the duplicate project.",
"isCommentAutoGenerated" : true
},
"A QR code will appear below. Scan it with WhatsApp on your phone. The session is saved to ~/.hermes/platforms/whatsapp/ so you won't need to scan again after restarts." : {
"localizations" : {
"de" : {
@@ -1391,6 +1395,10 @@
}
}
},
"Add a project" : {
"comment" : "A button that adds a new project.",
"isCommentAutoGenerated" : true
},
"Add a project folder to get started. Create a .scarf/dashboard.json file in your project to define widgets." : {
"localizations" : {
"de" : {
@@ -2521,6 +2529,10 @@
}
}
},
"Archived (%lld)" : {
"comment" : "A label that opens a group of archived projects.",
"isCommentAutoGenerated" : true
},
"Args (one per line)" : {
"localizations" : {
"de" : {
@@ -3785,6 +3797,10 @@
}
}
},
"Chats you start here get attributed automatically. Older CLI-started sessions live in the global Sessions sidebar." : {
"comment" : "A description of the purpose of the Sessions tab.",
"isCommentAutoGenerated" : true
},
"Check" : {
"localizations" : {
"de" : {
@@ -6874,6 +6890,10 @@
},
"Description" : {
},
"Destination" : {
"comment" : "A label for the folder picker in the move-to-folder sheet.",
"isCommentAutoGenerated" : true
},
"Details" : {
"localizations" : {
@@ -8802,6 +8822,10 @@
}
}
},
"Filter projects" : {
"comment" : "A label for a search field in the sidebar.",
"isCommentAutoGenerated" : true
},
"Filter servers..." : {
"localizations" : {
"de" : {
@@ -9006,6 +9030,10 @@
"comment" : "A placeholder for a comma-separated list of tags.",
"isCommentAutoGenerated" : true
},
"Folders only affect how projects are grouped in Scarf's sidebar. Nothing on disk changes." : {
"comment" : "A description of how folders affect project grouping.",
"isCommentAutoGenerated" : true
},
"Full copy of active profile (all state)" : {
"localizations" : {
"de" : {
@@ -9698,6 +9726,10 @@
}
}
},
"Hide archived projects" : {
"comment" : "A toggle that hides archived projects.",
"isCommentAutoGenerated" : true
},
"Hide details" : {
"localizations" : {
"de" : {
@@ -12186,6 +12218,22 @@
}
}
},
"Move" : {
"comment" : "A button that moves a project to a folder.",
"isCommentAutoGenerated" : true
},
"Move \"%@\" to folder" : {
"comment" : "A heading for a dialog that lets the user move a project to a folder.",
"isCommentAutoGenerated" : true
},
"Move to Folder…" : {
"comment" : "A context menu action that moves a project to a folder.",
"isCommentAutoGenerated" : true
},
"msgs" : {
"comment" : "A label for the number of messages in a session.",
"isCommentAutoGenerated" : true
},
"my_server" : {
},
@@ -12309,6 +12357,17 @@
}
}
},
"New Chat" : {
"comment" : "A button that starts a new chat session.",
"isCommentAutoGenerated" : true
},
"New folder name" : {
},
"New folder…" : {
"comment" : "A label for a new folder name.",
"isCommentAutoGenerated" : true
},
"New name for '%@'" : {
"localizations" : {
"de" : {
@@ -13327,6 +13386,10 @@
}
}
},
"No project selected" : {
"comment" : "A label that indicates that no project is selected.",
"isCommentAutoGenerated" : true
},
"No Projects" : {
"localizations" : {
"de" : {
@@ -15458,6 +15521,10 @@
},
"Project folder kept" : {
},
"Project name" : {
"comment" : "A label for a text field that lets the user enter a project name.",
"isCommentAutoGenerated" : true
},
"Project Name" : {
"localizations" : {
@@ -16870,6 +16937,10 @@
}
}
},
"Rename project" : {
"comment" : "A title for a sheet that renames a project.",
"isCommentAutoGenerated" : true
},
"Rename Session" : {
"localizations" : {
"de" : {
@@ -16949,6 +17020,9 @@
}
}
}
},
"Rename…" : {
},
"required" : {
@@ -19379,6 +19453,10 @@
}
}
},
"Sessions in this project" : {
"comment" : "A heading for the list of sessions in a project.",
"isCommentAutoGenerated" : true
},
"Set as default — open this server when Scarf launches." : {
"comment" : "A tooltip for the star button in the Manage Servers view.",
"isCommentAutoGenerated" : true
@@ -19623,6 +19701,10 @@
}
}
},
"Show archived projects" : {
"comment" : "A toggle that shows/hides archived projects.",
"isCommentAutoGenerated" : true
},
"Show details" : {
"localizations" : {
"de" : {
@@ -21435,6 +21517,10 @@
}
}
},
"The project directory on disk isn't changed — only the label Scarf shows in the sidebar." : {
"comment" : "A description of the project name field.",
"isCommentAutoGenerated" : true
},
"The remote's SSH fingerprint no longer matches what your `~/.ssh/known_hosts` file expected. This usually means the remote was reinstalled — or, less commonly, that someone is intercepting the connection." : {
"localizations" : {
"de" : {
@@ -22422,6 +22508,10 @@
}
}
},
"Top Level" : {
"comment" : "A folder in the sidebar.",
"isCommentAutoGenerated" : true
},
"Top Tools" : {
"localizations" : {
"de" : {
@@ -22582,6 +22672,10 @@
}
}
},
"Unarchive" : {
"comment" : "A button that unarchives a project.",
"isCommentAutoGenerated" : true
},
"Uninstall" : {
"localizations" : {
"de" : {