Skip to content

mac: the caret, third attempt β€” leading, not a line box - #311

Merged
stupart merged 1 commit into
mainfrom
feat/caret-again
Sep 17, 2026
Merged

stupart merged 1 commit into
mainfrom
feat/caret-again

Conversation

@stupart

@stupart stupart commented Sep 17, 2026

Copy link
Copy Markdown
Owner

"the cursor in the text input box still isn't lined up with the preview text"

#303 was my fix and it was wrong. It reasoned from a probe of a simplified case and shipped minimumLineHeight = maximumLineHeight = 22. CSS splits a line box's extra leading half above and half below; AppKit puts all of it above the baseline. So the line box pushed the first line down and grew the caret with it, while the top-aligned placeholder stayed put β€” a different gap, not a smaller one.

Measured against a real NSTextView, rendered and read back pixel by pixel:

caret text ink
no paragraph style 18 pt 4 pt
min/max 22 β€” what #303 shipped 22 pt 8 pt
lineSpacing 4 18 pt 4 pt

The placeholder draws at 4 pt. The middle row was off by exactly 4.

The fix is ConchType.readingLineSpacing β€” the same 4 pt the transcript already uses, so the composer and the messages it answers can't drift apart. #ta{font:var(--read)/22px} is 15 of type + 4 of leading; Self.lineHeight survives only as the one-line floor and eight-line ceiling.

Two hypotheses tested and discarded first

A harness hosting a real SwiftUI TextEditor showed the paragraph style survives every update (22/22 before and after a keystroke) β€” so "SwiftUI clobbers it" was wrong, and the introspector running once was not the bug.

The lesson: a probe of a simplified case is not a measurement of the thing itself. My first probe measured a SwiftUI Text in a frame and an NSLayoutManager in isolation. Neither was the composer.

Verified: bun test 2345/0 Β· tsc clean Β· swift test 138/0 Β· Mac builds Β· 5 mutations, 5 killed β€” including one that re-ships #303's exact mistake, so it can't come back quietly.

πŸ€– Generated with Claude Code

https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE

Tyler, twice: "the cursor in the text input box still isn't lined up with the
preview text."

#303 was my fix and it was wrong. It reasoned from a probe of a SIMPLIFIED case
and shipped `minimumLineHeight = maximumLineHeight = 22`. CSS splits a line box's
extra leading half above and half below the text; AppKit puts ALL of it above the
baseline. So a 22 pt line box pushed the first line DOWN and grew the caret with
it, while the placeholder β€” top-aligned, untouched β€” stayed where it was. That
made the gap different, not smaller.

Measured against a real NSTextView, rendered and read back pixel by pixel:

    no paragraph style   caret 18 pt, text ink at 4 pt
    min/max 22           caret 22 pt, text ink at 8 pt   <- what #303 shipped
    lineSpacing 4        caret 18 pt, text ink at 4 pt

The placeholder draws its ink at 4 pt. The middle row was off by exactly 4.

So: `ConchType.readingLineSpacing` β€” the same 4 pt the transcript already uses,
so the composer and the messages it answers cannot drift apart.
`#ta{font:var(--read)/22px}` is 15 pt of type plus 4 pt of leading, and 22 still
bounds the field at one line and at eight (`max-height:calc(22px*8+12px)`).

Two hypotheses were tested and discarded first. A harness hosting a real SwiftUI
TextEditor showed the paragraph style SURVIVES every update (22/22 before and
after a keystroke), so "SwiftUI clobbers it" was wrong and the introspector
running once was not the bug.

The lesson, which cost three attempts: a probe of a simplified case is not a
measurement of the thing itself. The first probe measured a SwiftUI Text in a
frame and an NSLayoutManager in isolation. Neither was the composer.

Verified: bun test 2345/0, tsc clean, swift test 138/0, the Mac app builds. Five
mutations, five killed β€” including one that re-ships #303's exact mistake, so it
cannot come back quietly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE
@stupart
stupart merged commit 58faec9 into main Sep 17, 2026
stupart added a commit that referenced this pull request Sep 19, 2026
…ng at depth

Tyler, four times: "the cursor in the text input box still isn't lined up with the
preview text". The first three attempts reasoned about it. This one measured.

`lineSpacing` never enlarges the FIRST fragment β€” it is 18 pt, not 22. The caret
rides high because AppKit draws it to the line fragment, whose top is the ascent:
system 15 has ascender 14.50 against capHeight 10.57, so the caret clears the cap
line by 3.9 pt and the descender by 0.5. #311 was right and is untouched.

Four seams nearer the caret are dead, each measured against a real NSTextView.
`drawInsertionPoint(in:color:turnedOn:)` is never called at all: under TextKit 2
the caret is an `NSTextInsertionIndicator` SUBVIEW. Its `bounds` clips rather than
moves, its `frame` and `layer.transform` are both reset by AppKit, and
`.baselineOffset` is absorbed by the typesetter under both TextKits. The only seam
that moves it is an `NSLayoutManagerDelegate`, and reading `layoutManager` forces
the TextKit 1 fallback. That is the price, and it is the whole price.

`ComposerCaretBaseline` raises the glyphs half the leading inside the fragment;
`ComposerView.caretRaise` slides the editor down by the same half. The two cancel
for the words and do not for the caret, so nothing moves but the thing that was
wrong β€” the field's height and the gap to the controls are unchanged, and the
placeholder is deliberately not compensated. Measured on the shipped binary:
7 px above and 1 below becomes 3 above and 4 below.

The introspector stops hard-coding its hop. It reached the editor by going exactly
two superviews up from its probe and gave up silently; in a harness around a real
TextEditor that came up empty in 6 launches out of 14. A miss is invisible and
total β€” no leading, no caret fix, no spelling, and a dropped file arrives as a
path, which is the "tried dropping an image, process was kinda weird, and idk if
it worked" from earlier. It now walks up until an ancestor holds the editor, stops
at the window, and looks again next runloop up to ten times.

ComposerPasteBridge had the identical bug twelve lines away and gets the same
treatment: `sharesAnAncestor` rather than a fixed hop. That is why a pasted image
vanished with nothing on screen to say so.

Five mutations, five killed: a full revert, the sign flipped, the editor not slid
back, the fixed hop restored, the retry dropped.

Verified: bun test 2376/0, tsc clean, swift test 138/0, the app builds, signs and
installs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE
stupart added a commit that referenced this pull request Sep 19, 2026
…dex session start, and the shared renderer (#315)

* The overlay is a glass panel: Apple's Liquid Glass, a rounded rect and a hairline

Tyler: "maybe just try using the apple glass panel material with some nice shader
color in it... make a cool glass using their materials and in a nice rounded panel
with a border."

The fog is retired as the overlay's look. `ConchGlassPanel` draws Apple's Liquid
Glass (`glassEffect`, SwiftUICore, macOS 26) in a `ConchRadius.panel` rounded rect
with a `ConchColor.hairlineStrong` edge, panel.html's 44x5 grab bar, and a mesh
gradient under the glass tinted by the voice's hue. Below macOS 26 it falls back to
`ConchColor.glass` over the host's own blur, so the deployment target stays 14.0.

Three treatments were rendered over flat light and flat dark grounds before
choosing: plain glass and a 0.16 violet tint were indistinguishable from each other
in both schemes; only the tint-plus-mesh read as its own thing, and it holds that
character on either ground.

The window is now exactly the fog. The glass ends at its own rounded edge and AppKit
draws the shadow from that shape, so `margin` is always zero β€” which also retires a
bug I shipped and reverted earlier tonight, where the window reached past the fog,
`setFrameAutosaveName` saved the WINDOW frame, and `place()` restored it as the FOG
size, growing the overlay on every relaunch (760 -> 774 -> ...).

The effect view stays a sibling in `[blur, lookHost, words]`, hidden where the OS
draws glass itself. Removing it would have deleted the subject of the guard that
protects "when i press the (v) it just disappears", so it stays and that guard keeps
its teeth. `FogLook` is untouched: FogLookTests (9) and FogTextTests (11) still pass
as they were, and FogMotionTests β€” the 400 seeded episodes and the lab's throw paths
β€” never sees this change at all.

The words' blob fade becomes panel.html's linear one
(`linear-gradient(transparent 0,#000 26%)`), running from whichever end holds the
oldest words (`newestAtTop`).

The default size is the lab's 900x640 rather than 760x560, which was the seed value
the port started from and never a decision.

Verified: bun test 2357/0, tsc clean, swift test 138/0, the Mac app builds, signs
and installs. Guards repointed in the same commit: the look layer, the zero margin,
the words' fade, and new ones pinning the glass availability branch and its
pre-26 fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE

* The panel floats off the corner, so all four of its corners round

Hung flush in the screen's corner, two of the panel's edges are screen edges: only
the top and the free side round, the shadow has nowhere to fall on the other two,
and it reads as a slab cut into the screen rather than a panel sitting on it.
panel.html floats it instead β€” `left:24px;bottom:24px`.

The glass is inset inside the window rather than `FogDock.frame` being moved. The
docked frame is a contract: ConchDesignTests pins `.bottomLeading` to the screen's
origin and `.topTrailing` flush at the far corner, FogMotionTests measures the
settled frame against the screen's edges across 400 seeded episodes, and `gaps` β€”
the distance to each edge β€” drives the magnet, which drives where the words sit.
Insetting the dock would move all of that for a cosmetic gain. Insetting what is
drawn moves none of it, and leaves a small grab margin around the glass.

Verified: swift test 138/0, the guards pass, the Mac app builds, signs and installs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE

* The words come inside the glass with it

The panel floats 24 pt inside the window, so what it holds has to come in with it or
it sits beside the panel rather than on it. The padding goes on `ConversationFog`
INSIDE the host's Group: `FogControls` reports its frames in the coordinate space
that Group names, and `grabs` matches presses against them in the container's space,
so content and frames move together there. One level up they would not, and every
press on a button would quietly miss by 24 pt.

The Dock and the menu bar stop at the screen's edge, which the panel now starts 24 pt
in from, so their overlap is that much less (`EdgeInsets.less`): without it the words
are pushed in twice on the docked sides.

The buttons still place themselves from the corner they are given and stay out at the
window's β€” `buttonInsets` deliberately zeroes leading and bottom, since the Dock does
not reach into a corner. That is the next commit, in ConversationFog rather than here.

Verified: bun test 2357/0, tsc clean, swift test 138/0, the Mac app builds, signs and
installs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE

* The panel resizes to the whole screen, and its reply line loses its focus ring

Tyler: "can we make it so that I can have the panel fill the pt height and go to the
edges like a normal window?"

`FogDock.maxSize` was the lab's 1280 by 900. That is the lab viewport's own
convention, not a rule about the panel, and on a 1728 by 1117 screen it stopped the
panel 217 pt short of full height β€” it could not be dragged to fill the screen the
way any other window can. It is now the screen itself. `minSize` stays 480 by 360 and
the rubber band past either limit is unchanged.

Three pins moved with it, in this commit: the maxSize assertion, the resize gesture
that springs back to its tallest (900 -> 1117, the test screen's height), and the
source-text guard. The 1024 by 768 case asserts the cap never exceeds the screen and
passes either way, so it stayed as it was.

And the fog's reply editor sets `focusRingType = .none`, which every other editor in
the app already did β€” TranscriptFallback, DaemonLogDrawer and the review editor all
turn it off. Clicking the panel drew a ring around the reply line (Tyler: "theres a
weird select outline that forms when i click on it").

Verified: swift test 138/0, the guards 21/0, the Mac app builds, signs and installs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE

* One drained utterance is recovered once, not once per capture

Tyler: "just had a crazy bug where it pasted from teh conch app into ints convo
bar and got into a loop until i closed the convo bar" β€” his sentence arrived in
the composer six times, space-joined.

My first diagnosis was wrong and was measured to be wrong before anything was
built on it. I believed deliver()'s eleven publishDictation sites each
republished one utterance, so the app appended it once per recovery. Driving a
single utterance through nine failure shapes and printing the dictated id delta
gives 1 every time: deliver() publishes at most once per invocation, and a
publish-once closure there would have been a no-op.

The live evidence said otherwise. /tmp/conch-sessions.json held the corruption
INSIDE one published dictation's text (id 8), so the app's id guard was never
involved. Decomposed, the 502 characters are six copies of an 83-character unit
as five array elements space-joined β€” the signature of captured.join(" ") in
recoverIncompleteDictation. The daemon log named the caller: the incident logged
nothing and had no `heard:` lines, so it never went through the main reducer β€”
it came from the silent exit drain, which pushes every late transcript it finds
with no dedupe.

One utterance, five overlapping captures in the backlog, joined blind. The
dedupe goes in the shared settlement both recovery callers route through rather
than in each caller. Distinct segments still join normally.

Mutation-checked both ways: reverted to the bare join, the new test fails with
the incident's exact shape; restored, it passes.

Verified: bun test 2368/0, tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE

* Codex refuses the pair conch was sending it, so conch refuses it first

Tyler, starting a session: "error: the argument
'--dangerously-bypass-approvals-and-sandbox' cannot be used with
'--ask-for-approval <APPROVAL_POLICY>'". Codex exits 2 at parse time, before the
session exists, and what that looks like in conch is a usage dump in a Terminal
nobody is watching and a session that never checks in β€” the same shape as a
hang.

Two independent sources each added flags and neither knew about the other: the
bypass flag from the PERSISTED bypass-permissions setting, and --sandbox /
--ask-for-approval from the start options the sheets offer as free choices.
startOptionsError validated each option in isolation β€” name, kind, enum
membership β€” with no notion of a pair, so the combination passed conch and died
at codex.

StartOption grows `conflictsWith`, the CLI's own conflicts_with, declared on
codex's bypass row. The refusal is one guard at the shared chokepoint, so it
covers the socket validator, the TUI overlay, `conch start` and the terminal
launcher alike, and it fires whether the flag came from the request or from the
setting. `conch start` no longer dies in an unhandled rejection: a refused pair,
a missing binary or a bad folder prints one line and exits 1.

Checked against the installed codex-cli 0.154.0, not from memory β€” and note that
`codex --help` exits 0 WITH the conflicting pair, so a --help dry run can never
catch this class. Only a pinned argv can.

Mutation-checked three ways: dropping conflictsWith fails 6, re-emitting Tyler's
exact broken line fails 4, dropping the CLI's try/catch fails 1.

Verified: bun test 2368/0, tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE

* One markdown renderer for both transcripts, and a column that fills the panel

Tyler: "formatting of thsi text is terrible".

The overlay was never plain text β€” it already parsed inline markdown, so
emphasis, code spans and line breaks survived. What was actually broken is
narrower: `## Heading` rendered showing its hashes, a table arrived as a wall of
pipes with its `|---|` divider row intact, and links had nothing to mark them.
The dashboard had solved all three; the fog had half the renderer.

So there is one renderer now, in the package, and the dashboard forwards to it β€”
the two transcripts cannot disagree about what a reply looks like. The parse
stays `.inlineOnlyPreservingWhitespace` because the block parse drops every
newline, which would run a list into one paragraph. The rationale moved with the
code rather than being deleted with it, including why the underline is permanent
rather than on hover: `Text` draws an AttributedString as one view and cannot
hit-test a run inside it, so a hover underline would be a lie.

Real bullets, indents and heading sizes are deliberately still out: they need a
view per block, and the word reveal indexes one Text.

The reading column goes 540 -> 620. The panel defaults to 900 wide and resizes to
the whole screen now, and 540 left 312 pt of an 852 pt fog empty. It cannot
simply follow the width: `pull` slides the column between its docked side and the
centre by (usable - cap), so a cap that fills the fog kills the magnet outright β€”
0 pt of travel at 748 β€” and narrowing the panel kills it identically. 620 keeps
64 pt and puts the newest line at 61 characters.

Verified: swift test 138/0, bun test 2368/0, tsc clean, the app target compiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE

* Whisper repeating itself is not you saying it twice

The other half of the six copies. The recovery dedupe collapses identical
siblings in the drain backlog, but one of those siblings was a single transcript
in which Whisper had emitted the same 83-character clause twice over, back to
back, with no separator β€” a decoder repetition loop. cleanTranscript only
collapsed whitespace, so it survived intact and the incident resolved to three
copies rather than one.

The rule is the narrowest that catches it: the WHOLE string must be one span
repeated end to end with a uniform separator, the span at least 40 characters
and 8 words, and copies divided by a sentence boundary are left alone. Copy
counts descend so the smallest qualifying unit wins and a span repeated four
times collapses to one rather than two.

The thresholds come from the data, not from a round number. The observed
artifact unit was 83 characters and 17 words. The longest phrase this file
already treats as a whole short utterance is 22 characters; real utterances
measured from the daemon log have a first decile of 63. So 40 sits clear above
what is said deliberately and below the shortest tenth of genuine speech, and
eight words rules out one long token. Emphatic repetition β€” "no no no", "very
very" β€” repeats two- and four-character units and never comes near it.

Over-collapsing is the worse failure, because nothing on screen would say it
happened, so four negative tests guard it: short deliberate repeats, a clause
with words between the copies, a repetition split by a full stop, and an
ordinary sentence that merely repeats a word.

Fixture is the real clause read back out of the session snapshot, not a
synthetic one. Mutation-checked both ways: without the collapse both positive
tests fail with the incident's exact shape, and the four negative tests stay
green, so they do not depend on the fix.

With both layers in, the incident resolves fully: six copies to one.

Verified: tsc clean, transcribe 20/0, and all six whisper-related suites 47/0 so
the shared cleanTranscript did not disturb the warm or cold path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE

* Codex is not offered a model to override its own

Codex takes its model from its own config β€” gpt-6-astra at xhigh β€” and conch
never passed --model itself. But its start table carried a model row, mirrored
into the Mac sheet and the iPhone's, so the option was one click away in three
places. Offering it is the same thing as passing it, a moment later and with
nothing to say what it overrode.

The row goes from the adapter and from both sheets. Claude keeps its own, which
is correct: the rule is about codex, and claude's model is genuinely a per-session
choice.

Found while fixing the flag conflict, not fixed then: the agent that traced it
stopped rather than edit a Swift file it believed another agent held.

`conch start`'s parser test passed --model to codex and expected it back, so it
is repointed. Its "--model needs a value" case carries no agent argument, which
means claude, so the flag stays covered by a negative test.

Verified: start-options 55/0, the Mac target builds, and the iPhone target builds
on a simulator β€” both sheets edited, both compiled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE

* The TUI's codex rows lose their model too

The last pin of the row I removed: the start overlay enumerates the chosen
agent's options, and its codex list still named "model". Claude's list on the
line above keeps its own, which is right β€” the rule is codex's.

This was the one failure left in the suite, and it was deterministic rather than
flaky: two consecutive full runs reproduced it identically. It looked like noise
from concurrent work and was not.

Verified: session-start-overlay 7/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE

* The caret sits on the line, and the reach for the editor stops guessing at depth

Tyler, four times: "the cursor in the text input box still isn't lined up with the
preview text". The first three attempts reasoned about it. This one measured.

`lineSpacing` never enlarges the FIRST fragment β€” it is 18 pt, not 22. The caret
rides high because AppKit draws it to the line fragment, whose top is the ascent:
system 15 has ascender 14.50 against capHeight 10.57, so the caret clears the cap
line by 3.9 pt and the descender by 0.5. #311 was right and is untouched.

Four seams nearer the caret are dead, each measured against a real NSTextView.
`drawInsertionPoint(in:color:turnedOn:)` is never called at all: under TextKit 2
the caret is an `NSTextInsertionIndicator` SUBVIEW. Its `bounds` clips rather than
moves, its `frame` and `layer.transform` are both reset by AppKit, and
`.baselineOffset` is absorbed by the typesetter under both TextKits. The only seam
that moves it is an `NSLayoutManagerDelegate`, and reading `layoutManager` forces
the TextKit 1 fallback. That is the price, and it is the whole price.

`ComposerCaretBaseline` raises the glyphs half the leading inside the fragment;
`ComposerView.caretRaise` slides the editor down by the same half. The two cancel
for the words and do not for the caret, so nothing moves but the thing that was
wrong β€” the field's height and the gap to the controls are unchanged, and the
placeholder is deliberately not compensated. Measured on the shipped binary:
7 px above and 1 below becomes 3 above and 4 below.

The introspector stops hard-coding its hop. It reached the editor by going exactly
two superviews up from its probe and gave up silently; in a harness around a real
TextEditor that came up empty in 6 launches out of 14. A miss is invisible and
total β€” no leading, no caret fix, no spelling, and a dropped file arrives as a
path, which is the "tried dropping an image, process was kinda weird, and idk if
it worked" from earlier. It now walks up until an ancestor holds the editor, stops
at the window, and looks again next runloop up to ten times.

ComposerPasteBridge had the identical bug twelve lines away and gets the same
treatment: `sharesAnAncestor` rather than a fixed hop. That is why a pasted image
vanished with nothing on screen to say so.

Five mutations, five killed: a full revert, the sign flipped, the editor not slid
back, the fixed hop restored, the retry dropped.

Verified: bun test 2376/0, tsc clean, swift test 138/0, the app builds, signs and
installs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE

* Full screen keeps its background, and the reply line loses its ring for real

Two from Tyler, both regressions of my own making.

"on the converation overlay fullscreen mode the background fo teh panel
dissapears so let's have it keep the background so i can read the text."

The glass panel is a rounded rect in a corner, so `FogLookHost` leaves it out in
full screen β€” correct. But the commit that introduced the glass also hid the
behind-window blur whenever glass was in use, unconditionally. Full screen
therefore had NEITHER: the only thing painting was ConversationFog's wash, a
gradient over a completely unblurred desktop, which is why the words stopped
being readable. panel.html blurs the work AND washes it; I had kept only the
wash. The blur now returns exactly where the glass steps aside.

Nothing caught this because no test asserted that anything paints behind the
words in full screen. One does now.

"thers still a strange outline around teh component."

The earlier fix set `focusRingType = .none` on the reply's text view. AppKit
draws the focus ring on the enclosing NSSCROLLVIEW, which had only
`drawsBackground = false` β€” so the ring I turned off was never the ring being
drawn. It is off on both now.

Mutation-checked three ways, each killed: the full-screen gate reverted, the
full-screen unhide deleted, the scroll view's ring restored. Baseline 21/0,
each mutation 20/1, restored 21/0.

Verified: bun test, tsc clean, swift test 138/0, the app target compiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE

* A dictation already received is not received again on the next launch

Tyler: "this text keeps showing in the 'arch prime' session input box. i keep
delting it and it keeps coming back."

The fourth and last layer of the same incident, and the one that was putting it
back. `live.dictated` is sticky on the daemon's side and deliberately never
cleared β€” it has to outlive the state transitions that follow it, because the app
applies it whenever it next reads state. The id is the only thing that makes that
safe, and the app held the applied id in memory. Every relaunch reset it to zero,
so a dictation the user had already received, and deleted, looked new again and
was appended once more.

That is why it kept coming back for him specifically: conch was rebuilt and
relaunched around a dozen times while the earlier layers were being fixed, and
each relaunch re-applied the same `dictated id=8` β€” 502 characters, his sentence
six times over β€” into the Arch Prime draft. The earlier fixes stopped new
corruption being produced; none of them touched the copy already sitting in
daemon state.

The id now lives beside the drafts, read at init and written the moment a
dictation is applied.

The other three layers, for the record: Whisper emitted the clause twice with no
separator (46d8507); the silent exit drain joined five overlapping captures of one
sentence (a370445); and the sticky dictation itself, which is by design and was
cleared by hand along with the corrupted draft.

Mutation-checked: reverted to the in-memory id, the guard fails; restored, it
passes. Baseline 3/0, mutated 2/1, restored 3/0.

Verified: bun test 2385/0, tsc clean, swift test 138/0, the app target compiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE

* A wider panel earns a wider column, not a wider margin

Tyler, expanding the conversation panel: "pretty silly when i expand the convo
panel" β€” and it was. A nearly full-screen sheet of glass with the words in a
narrow strip down one side and about two thirds of the panel blurred nothing.

The column was a fixed 620. That is right at the size the panel is usually at and
absurd at 1900, where it leaves roughly 1100 pt empty. It now grows with the panel
toward 1040 β€” the measure full screen already uses β€” so a big panel reads like a
page instead of a ribbon.

Sixty per cent of the available room rather than simply following it, because
`pull` slides the column between its docked side and the centre by (room - width).
A column that FILLS its fog has nowhere to travel and the magnet dies outright,
which is the same trap that stopped the cap being raised to the full width
earlier. At 60% the default 900 pt panel is untouched at 620 with 176 pt of
travel, and a panel past about 1185 pt starts earning width.

The new test pins the shape rather than one number: unchanged at the default,
growing past the threshold, stopping at 1040 however wide the panel goes, using
what it has when the panel is too narrow for the floor, and β€” at every size β€”
leaving the magnet somewhere to go.

Mutation-checked: restored to the fixed 620 cap, three assertions fail; restored,
12/0.

Verified: swift test 138/0, tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE

* conch can photograph its own overlay, and measure what it sees

Most of a session's wrong UI measurements came from cropping a screenshot blind:
aiming at a stale window position after the panel was resized, cropping a band
that turned out to be another app entirely, scanning for a caret in a field that
had no focus. Every one was a reproducibility failure, not a judgement failure.

`conch shot` grows `--window overlay|dashboard|controlbar|key|geometry`, so the
floating panels can be photographed at all β€” they were deliberately excluded, and
the conversation overlay is the surface iterated on hardest. It is found
STRUCTURALLY, by `takesKeys`, not by its autosave name: that name is blanked while
collapsed or full screen, so a name lookup would find it in the dull states and
silently miss the interesting ones.

`--screen` is a first-class mode and the honest one for anything translucent.
Measured: an offscreen draw of the overlay comes back at mean alpha 217 of 255 β€”
its colours premultiplied over black rather than over the desktop β€” because blur
only exists where the window server composites it. Only a full-screen frame shows
what an eye sees. It also carries the context that makes a bad crop obvious.

Every shot writes a geometry sidecar BEFORE the picture: each window's frame, its
`imageRect` in pixels for cropping without arithmetic, key status, first
responder, appearance. That is the direct cure for the stale-crop failures, and it
earned itself during the work by catching the overlay moving twice while it was
being resized underneath.

`scripts/shoot-overlay.ts` drives named states from a manifest and refuses to
photograph a state that did not take. `tools/backdrop.swift` puts a known ground
under a translucent panel; `tools/pixels.swift` measures it.

The chain is validated physically, not by eye: the same state over black, mid-grey
and white reads luma 102.5 / 124.3 / 145.5. Transmission 16.9%, so the panel is
about 83% opaque, and the mid-grey predicted from the black and white pair is
124.1 against 124.3 measured β€” linear to a fifth of a luma level.

Two silent-wrong-answer bugs were found and fixed in the measuring itself, both
the same trap: `CGRect.integral` STANDARDISES a negative-width crop into a valid
rect elsewhere in the image, so a crop meant for a 144 px panel measured a 16 px
patch of backdrop and reported it as the panel β€” and the guard against that then
failed because `CGRect.width` also normalises, reading 16 for a width of -16. It
reads `region.size.width` now and refuses.

Six mutations, six killed.

Verified: tsc clean, swift test 138/0, and the documented entry point runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE

* The overlay comes back the size it was left

`setFrameUsingName` restores the ORIGIN of a borderless, non-resizable panel and
drops the size, so `place()`'s default won every launch and a fog resized by hand
was never the size it was left at. Measured twice while building the capture
system: asked for 480x360, got 900x640; asked for 600x500, got 900x640. The size
was reported, never requested.

The saved string is "x y w h screenX screenY screenW screenH", so the size comes
out of it directly rather than being left to AppKit. The control bar is unchanged:
its size is its content's, whatever was saved.

Mutation-checked: dropping the restore fails the guard; restored, 21/0.

Verified: swift test 139/0, tsc clean, bun test 2402/0, the app target compiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE

* Conch sees a turn as it happens, not when it ends

Tyler: "would be rally great / important if long terms conch streamed responses
from sessions in realtime."

The premise needed correcting first: conch was never terminal-scraping. It already
re-reads both transcripts through readConversationTail and the existing reducers on
every render. What was missing was a TRIGGER β€” nothing told it to render until a
turn ended. Claude only produced content at its Stop hook; Codex had no hook at all,
just a 5 second poll. So the fix is to point the watch that already exists at
transcript CONTENT rather than only at the liveness directories.

  Claude mid-turn   no trigger at all  ->  p50 122 ms
  Codex  mid-turn   5 s poll           ->  p50 127 ms

Measured by replaying real transcripts line by line into copies: 120 lines in, 121
updates out, for both agents. fs.watch itself fires in about 11 ms; the rest is the
deliberate publish ceiling.

The byte cursor the plan called for is NOT here, deliberately. Reading the tail of a
234 MB Claude transcript costs 2.4-6.0 ms and a 702 MB Codex rollout 1.8-3.6 ms,
because tailDepth already bounds the read to a 512 KB window β€” a cursor would save
single digit milliseconds. records-source.ts's "PR 6 will read these bounded ranges"
comment is stale: records-indexer.ts already does that in a worker, feeding a
separate pipeline. Marked with a ponytail comment naming the ceiling.

One bug found in my own first cut: the debounce sat upstream of the publish, and a
resetting debounce never fires while a file keeps being appended to β€” precisely the
busy turn this exists for. Against a real file appended every 10 ms for 2 seconds,
the repo's existing 150 ms default produced ONE render. It now debounces at 50 ms
into the throttle that already has a leading edge and a 250 ms ceiling, pinned by a
real-filesystem regression test.

Nasty cases all covered against real data: a partial trailing line is held back
until its newline arrives, a line split across two writes, truncation to zero and
recovery, truncation mid-line, rotation by rename-over, and a file that does not
exist yet.

Five mutations, five killed.

The settle loop in hook.ts stays. It runs in a separate short-lived hook process
that reads the transcript itself to produce spoken text; this watcher lives in the
daemon and drives the panel. Nothing here reaches that process.

Still out of reach: token-level streaming. The files carry no delta records, so
per-message IS the on-disk granularity.

Verified: bun test 2402/0, tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE

* A session waiting on permission says so, instead of saying it is working

Tyler: "permissions on is broken - not getting notified that there were permissiosn
questions it just said it was working."

Two independent defects, one per agent.

CODEX β€” the one he hit, and it was both status and announce. readCodexRolloutTail
scans the rollout tail backwards and returns busy on the first task_started;
codexThreadStatus maps that to busy; registryToPanel turns busy into "working". A
thread parked on an approval therefore reports working forever. Codex publishes no
permission notification and conch's install wires only Stop, UserPromptSubmit and
SessionStart, so nothing existed to say otherwise.

The evidence is his own rollout: a custom_tool_call carrying
sandbox_permissions "require_escalated" and the justification "May I download the
locked Seashell dependencies", with no output for that call_id, unanswered for
hours. Run through the real reader it returned busy; through the fix it returns
needs.

CLAUDE β€” announce only. The approval was gated on !cfg.bypassPermissions, and his
settings have bypass-permissions true, so every permission prompt on his machine
was silent. That setting decides how conch STARTS a session; it cannot bind a
session conch did not start, one already running, or a Codex thread under
approval_policy "on-request". The gate is gone.

Claude's STATUS path was never broken β€” driving a real session into a dialog under
a pty showed the registry going busy -> waiting/"permission prompt" in about 3
seconds, and registryToPanel already maps waiting to needs. That killed a third
suspected bug and saved an edit to a deliberately tested path.

Keyed on the escalation rather than on an unresolved call: the latter would flag
every long-running command as blocked. Codex asks announce and show "needs you",
but conch never presses keys at them β€” APPROVAL_KEYS are Claude's dialog rows, and
aiming that blind key walk at Codex's UI could press the wrong thing. Answering is
yours.

Four mutations, four killed, src restored byte-identical.

daemon.ts is co-edited: the transcript watcher landed an import and three additive
sites here in the previous commit's work, and this adds the Codex approval timer.
They touch no shared logic and the suite is green with both.

Verified: bun test 2402/0 (from ~2376, +26), tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE

* A backlog Tyler does not have to keep

Tyler: "What's a efficent way to me to commmunicate all the bugs/fetures and we
make sure htey acctualyl get fixed?"

He should not have to track anything. He says it; it lands here the same turn.
Seventeen open and eighteen done, every open item in his own words where he gave
them, so nothing is lost to a paraphrase.

The rule at the top is the part that matters: nothing moves to done without
evidence beside it β€” a commit, a measurement, or a picture. "Should be fixed" is
not evidence. Several things tonight were called fixed and were not: the caret
took four attempts, the focus ring was turned off on the wrong view, and a
dictation loop was "fixed" three times before the fourth layer was found.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE

* Green means come and look; working says nothing at all

Four changes, all Tyler's, all about what the ledger is actually telling you.

WAITING IS GREEN. "does orange dot mean its waiting for me? We should make that
green or blue or something." It did: `accessibilityLabel` has said "Waiting for
you" all along, and the colour said otherwise. It now sits in review's family,
because a finished turn sitting on you and a deliverable sitting on you are the
same request. The glyph already separated them β€” waiting is `circle.inset.filled`,
review is `checkmark.circle.fill` β€” which is exactly what he asked for: "maybe do
same green circle just with no check?". Only the colour moved.

Not review's own #30B35A, though. A mark carries no text so it needs 3:1, and that
green measures 2.41 to 2.72 on the light grounds β€” the same failure the palette
note already records for the review gold at 1.3:1. #279B4C is the nearest green
that clears it on every ground in both schemes, 3.16 at worst. The orange it
replaces failed too, at 2.11 to 2.22, so this is a fix in both directions.

This reverses a documented decision. Waiting was moved to the orange end to read
"as attention rather than inert grey" and to part it from review when the two were
"20/255 apart in a single channel". The glyph does that parting now.

WORKING SAYS NOTHING. "have working be ... no icon or color at all since its
working." A session doing its job is not asking for anything, and a coloured dot on
it competes with the ones that are. It recedes to the faint ink idle uses. The dot
itself stays: without it, working and idle would be indistinguishable, which is a
different fact rather than a quieter one.

OLD DELIVERABLES LEAVE THE CONVERSATION. "this one is showing green circle with a
check tho becuase of a really old deliverable that shows at the bottom of teh
chat." The stack pinned one artifact to the end unconditionally β€” its own comment
says "for one-artifact-per-session IS where it happened", and that assumption is
what broke. Only an unviewed one shows inline now, on `viewedAt`, the same test the
ledger already calls `isUnviewed`. An older daemon never reports it, so it keeps
today's behaviour rather than silently hiding every card.

THE BAR ABOVE A DELIVERABLE IS GONE. "i don't get what its for an it adds clutter /
jank." A review check, the session's name in brand cyan, and the summary again:
three restatements of what the pane already is, above a picture of the work. The
check duplicated the ledger row's mark, the label duplicated the header, the
summary duplicated the card that opened it. The stage control was the one part that
was not a restatement, so it survives as a plain button over the work β€” it is the
only way to reach side-by-side and fill-the-stage with a mouse.

The origin bar below it stays. It looks like decoration and is not: a deliverable is
an agent-authored URL rendered full-bleed in conch's own chrome, so naming the
origin is what keeps a third-party sign-in page distinguishable from conch's UI.

Four mutations, four killed: waiting back to orange, working loud again, the card
ungated, the caption restored. Baseline 0 fail, each mutation 1, restored 0.

Verified: swift test 139/0, tsc clean, bun test 2402/0, the app target compiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE

* The sidebar is draggable, and remembers where you left it

Tyler: "wnat ot be able ot collapse and open the left side bar / drag to change
side of main area and therefore make it smaller if I want."

Collapse already existed β€” Cmd-B, with a note that bringing the sidebar back after
you deliberately closed it "is a window arguing with you" β€” but there was no
handle, and the width was a hardcoded 264 that no amount of dragging could change.

A grab strip sits on the ledger's edge: two points to look at, ten to hit, because
a resize you have to aim for is one nobody finds. The pointer changes over it. The
drag is live and the result is banked on release rather than written on every
frame, and it survives a relaunch in `conch.sidebarWidth`.

Bounded 180 to 520. A session's name needs room to be read, and a sidebar that can
eat the stage is a sidebar that can hide the work.

Mutation-checked, and the first attempt at the guard was WRONG: asserting the bare
name `sidebarResizer` also matched its own declaration, so deleting it from the
layout left the suite green. Anchored to the layout line instead, and then
deleting it fails as it should. The width-as-constant mutation fails too.

Verified: swift test 139/0, tsc clean, bun test 2402/0, the app target compiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE

* The sidebar is draggable, and remembers where you left it

Tyler: "wnat ot be able ot collapse and open the left side bar / drag to change
side of main area and therefore make it smaller if I want."

Collapse already existed β€” ⌘B, with a note that bringing the sidebar back after
you deliberately closed it "is a window arguing with you" β€” but there was no
handle, and the width was a hardcoded 264 that no amount of dragging could change.

A grab strip sits on the ledger's edge: two points to look at, ten to hit, because
a resize you have to aim for is one nobody finds. The pointer changes over it. The
drag is live and banked on release rather than written every frame, and it survives
a relaunch in `conch.sidebarWidth`.

Bounded 180 to 520. A session's name needs room to be read, and a sidebar that can
eat the stage is a sidebar that can hide the work.

Two guards moved with it. `title-bar-source` pinned the fixed width and explained
WHY it was fixed β€” "it used to scale with the window, so the stage's measure moved
every time the window did" β€” which is now the opposite of what the code does; a
stale comment misleads the next reader worse than a stale assertion does, so both
were rewritten. Its section delimiter still resolves, since the old declaration is
a prefix of the new one.

Mutation-checked, and the first guard was WRONG: asserting the bare name
`sidebarResizer` also matched its own declaration, so deleting it from the layout
left the suite green. Anchored to the layout line instead, and it bites. The
width-as-constant mutation fails too.

Verified: swift test 139/0, tsc clean, the app target compiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE

* The sidebar has a button, not just a shortcut

⌘B put the sidebar away and a Session menu item said so, but nothing on
screen did. Tyler: "i see the sidebar drag but how do i full close /
collapse it?" A shortcut you have to be told about is not an affordance.

A `sidebar.leading` HeaderButton now leads the title strip, just inside
the traffic lights, where every Mac app puts it. It posts the same
notification the menu item does rather than touching the flag, so the
button and ⌘B are one code path and the collapse animates identically
whichever you use β€” and the button stays visible while collapsed, which
is the state that most needed a way out.

Reuses HeaderButton rather than hand-rolling one: same 28pt frame, same
three hover/selected states, same help-as-accessibility-label.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE

* The backlog records the workspace-pane status, and the sidebar as done

Tyler asked where the terminal, file tree, diffs and browser stand. The
answer is one of four: the browser exists (DeliverableWebView), diffs are
partial and deliberately not unified (DiffLine, Models.swift:563), and
the file tree and an embedded terminal do not exist at all. Written down
with the file:line behind each claim so the next read does not re-derive
it β€” and so the honest part, that a terminal needs a PTY the daemon has
never had, survives being asked again.

Also folds in the Direction note from the previous turn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfEX6bfwCGXtThk1JRNmsE

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant