Repository navigation
mac: the caret, third attempt β leading, not a line box - #311
Merged
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
#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:min/max 22β what #303 shippedlineSpacing 4The 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.lineHeightsurvives only as the one-line floor and eight-line ceiling.Two hypotheses tested and discarded first
A harness hosting a real SwiftUI
TextEditorshowed 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
Textin a frame and anNSLayoutManagerin isolation. Neither was the composer.Verified:
bun test2345/0 Β·tscclean Β·swift test138/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