Two reports, both against 376a938, whose message claimed to fix them. Both
of its explanations were wrong. These two are measured.
Folding Pinned or Chats crashed with "No Observable object of type
WindowModel found". Not the mid-dispatch teardown the last two commits
described — deferring the write and freezing the item value were both
tried and both missed. Every list here hosts its rows in a bare
NSHostingView and injects only locale and colour scheme, so WindowModel
reaches a row purely by inheritance from an ancestor; ListViewKit parks
recycled rows in a reuse pool with no superview, and a fold detaches them
inside the very transaction the click started. A standalone harness
reproduces the trap verbatim in two shapes — a row configured before it is
attached, and a detached row re-rendering — and shows injection fixing
both. So each row's root now carries the window, the row empties itself
when it leaves the hierarchy, and the renderer takes the window as an
argument instead of reading @Environment from an AppKit callback.
The connection journey jumped upward the instant a connection succeeded,
and only with History open in the sidebar. That "only" was the clue, and
Kaede kept pointing at it: History is the one sidebar mode with a child
that demands height, and it appears exactly when status.isLive flips. Its
shell-integration banner used fixedSize(horizontal:vertical:), which
reports whatever height the sentence needs at whatever width it is asked
about — and SwiftUI asks about very narrow ones while sizing the column.
Ninety characters at a probe width wrap into a paragraph, the split view
adopts that as the height it must offer, and both columns are proposed it:
the detail column was handed 912x1125 instead of 912x688, which threw the
journey to y = -244.5 for one layout pass. A line limit bounds it.
Measured A/B/A on that one modifier: banner off, no inflated proposals;
banner on with fixedSize, 740 and 1125; banner on with lineLimit, none.
A second overflow of the same class, found while measuring and fixed here
too: the journey was a ZStack sibling of the terminal surface, so the pane
was as tall as its tallest child and a short pane reported 512 for a 440
proposal. frame(maxHeight: .infinity) passes that up rather than clamping
it. The journey is an overlay now — proposed the base's size, never
reporting one — so the terminal alone answers for the pane.
Not verified: the test suite was not run, and neither fix has been
confirmed by hand in the running app.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Floating the status bar fixed it reading as chrome bolted under the
content, and created the problem floating always has: it sits over the
pane. On a terminal it covers the last couple of lines in that corner,
and the only way out was closing the session.
It collapses to its status dot now, and expands again on click. The
preference is app-wide and persisted, beside the sidebar's section flags:
how much of the pane you are willing to have covered is a standing
choice, not a property of one session.
Collapsed is the same capsule, not a second container. A capsule as wide
as it is tall is a circle, so the shape morphs instead of cross-fading
between two shapes, and both states run through one chain from
`capsule(_:)` down so radius, height and corner placement cannot drift
apart. The height is identical either way, which is what lets the command
generator and the upload notices keep reserving one constant clearance.
`StatusBarPresentation.collapsedGlyph` answers two questions at once —
whether the capsule appears at all, and what it shrinks to. Asking them
separately is how you end up with a capsule on screen with nothing to
collapse into. The connection wins over a running transfer: a transfer is
something happening, the connection is the thing you are looking at.
`transferGlyph` moves out of the private transfer button into its own
view for the one case where the collapsed circle is not a connection dot
— a transfer running with no session in front of it.
fetch returns a page through the conversation, bounded at 256 KB. So a
page larger than that could not be read at all — the tail was truncated
and there was no second call that would reach it — and a page you meant
to refer back to was paid for twice, once to read and again to quote
into a file.
scratch_pull already solved this for hosts: it lands a remote file in
the workspace and returns a size and a digest, which is what lifts
sftp_read's inline ceiling. The network had no equivalent. scratch_fetch
is that, and its bound is the workspace's own 8 MB per-file limit rather
than what fits in a reply — so a large page arrives whole and is read a
window at a time with scratch_read.
A separate tool rather than a scratch_path on fetch, on the same grounds
scratch_pull and scratch_push were split out of sftp_read and
sftp_write. The flag would decide the tool's Safety — locallyReadOnly
without it, locallyContained with it — and one descriptor meaning two
things is harder for a model to choose correctly and harder to audit.
fetch is unchanged, byte for byte, so nothing already in a conversation's
history changes meaning.
locallyContained is the sum of its halves: reading a page, public or
private-network, is what fetch already does un-gated and never escalates
for, and the landing site is storage the app owns. It therefore needs no
approval and stays available while planning, which is right — staging
the documentation you are planning against is planning. The result
carries the title, size and digest and no page text; the title is enough
to confirm the right page arrived.
Both paths are proven in preflight, so a bad URL or a destination that
escapes the scope costs nothing rather than a page fetched and dropped.
An empty extraction fails instead of staging a zero-byte file the model
would then try to read.
851 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Workspace is built by folding two together, but there was no single
move that took one back apart — you dragged each pane out to the
Navigator one at a time, and the last one could not be dragged out at
all, because a pane alone in its Workspace has nothing to detach from.
Separate Panes on a merged Workspace's row is the inverse of merge: one
Workspace per pane, in layout order, sessions untouched.
The status bar was a docked strip with a hairline above it, reading as
window chrome bolted under the content. It is a glass capsule now,
floating in the trailing corner of the pane it describes. It hugs its
text rather than spanning the width — a full-width bar over a terminal
covers whole lines of output, and the prompt is on the left — and stops
growing at `maxWidth` so a deep working directory truncates instead.
Floating means it no longer takes layout space, so switching to a
Workspace stops resizing the content area. It also means it sits over the
pane, so the command generator and the upload notices now clear it by
`StatusBar.overlayClearance` — a constant rather than a measurement, and
applied whether or not the capsule happens to be showing, because a
notice that moves depending on whether a transfer is running is worse
than one that is always in the same place.
844 tests pass.
Three fixes and a feature, all in the titlebar's blast radius.
A toolbar with nothing in it is shorter than one holding a search field,
and AppKit takes the window's content up with the titlebar when it
shrinks. Connecting to a host from the sidebar changes the destination
from Vault to a Workspace, which swapped a Find field for nothing — so
the connection journey appeared and immediately jumped upward. The find
item now reserves its height at zero width on destinations with nothing
to search, so the toolbar is one size everywhere.
The search field lost the keyboard after one letter because being
searched is not a passive act: Ghostty focuses its terminal surface when
a `search:` binding runs, and the SFTP list is held focused
declaratively and takes it back when filtering rebuilds the rows. The
field now checks, immediately after a text change, that it still has the
keyboard — and only then, so clicking away deliberately still works.
Folding Pinned or Chats crashed. The expansion flag lived in the list
item, so folding changed that item's value, and ListViewKit answered by
reconfiguring the header's hosting view — replacing the root view of the
Button still dispatching the click. The row reads and writes the flag
itself now; the item carries only the count, so a fold adds and removes
conversation rows and never touches the header.
The titlebar names what is in front: the page, the Workspace, or — in
the Assistant — the conversation, because "Assistant" is where you are
but the chat is what you want to read from across the desk. Welcome is
named after the app. Resolved through a catalog of providers rather than
a switch in WindowModel, so the window does not have to know that a
Workspace is named after its host; the same shape ContextPanelDetailCatalog
already uses.
842 tests pass.
The window had no toolbar at all and four competing Find fields: a strip
on each Vault page, a bar inside the SSH pane, a field in the SFTP path
bar, and the sidebar footers. Which one Command-F meant depended on where
the caret happened to be, and two could be on screen at once.
There is now one native NSSearchField in the titlebar, pointed at whatever
content is in front — see WindowModel.findKind, which resolves a split
Workspace to its focused pane rather than to "any session in it". Creation
verbs sit opposite it in a Notes-style square.and.pencil control, chosen
from the main content and never from the sidebar mode. The dashed New
tiles and the page toolbars are gone; the card canvas answers a right-click
with the same verbs, scoped to the section under the pointer.
Return and Command-G used to disagree about which way a terminal search
walks. Both now mean Ghostty's .previous, which is the direction that finds
matches when a search starts at the bottom of the buffer.
The sidebar's own bottom search stays, because it narrows the sidebar's
list rather than the content in front. Its three history modes are one card
with four fillings on one list technology, so their edges line up and
opening a host no longer makes Recents jump under the pointer. Every row
draws its right-click menu natively and outlines itself on the filled
shape; SwiftUI's contextMenu highlighted the row plus its margin.
Fixes found along the way:
- The terminal re-claimed first responder on every SwiftUI update, so
typing in any field outside the pane lost the caret after each keystroke.
- Folding Pinned/Chats reconfigured the header's hosting view from inside
the click that asked for it.
- The Assistant's drop container cached the dragged id from a render pass
AppKit's modal drag loop can start before, hiding the insertion line.
- The host chooser inferred sheet-vs-column from the front destination, so
the sheet raised from the Snippets shelf over Vault never dismissed, and
it had no height to show a host in.
Steering, the composer and the two composer popovers now share one glass
surface. Tags are derived from hosts, so deleteTag goes with the popover
that called it.
838 tests pass. The visual claims are unverified: screencapture returns
black frames in this environment.