fix(terminal): make clicking a pane activate it, from the page side - #108
Merged
Conversation
Clicking a terminal has never made it the active session. #93 added a PreviewMouseLeftButtonDown handler on the pane's host Border, but WebView2 is an HwndHost: mouse input landing on hosted native content does not raise WPF routed events at all, tunnelling ones included. That handler only ever fired for the ~2px ring around the terminal, never for the terminal itself. So #93 only ever worked for the sidebar path, and — after #106 — for typing. Reported as "still won't switch tabs in the left menu when I click and put focus on another window", which is exactly right. The click can only be observed from inside the page, so terminal-init.js now posts an 'activate' message on mousedown (throttled to 300ms, since promotion is idempotent) and TerminalBridge raises PaneActivated. MainViewModel promotes on that and on KeyboardInput through one shared handler. The page-side handler also calls fitAddon.fit(). The grid rebuild that used to run on every activation incidentally forced a layout pass and therefore a re-fit; #105 skips that rebuild when nothing visible changes, so the re-fit has to be explicit now. Without it xterm's column count can drift from what the PTY was told, and redraws land a character off — reported as typing starting one character into Claude's placeholder, leaving a stray leading "T" from 'Try "how do I log an error?"'. Corrected the WPF-side comment, which claimed the handler covered clicks in the pane. It never did. 312/312 pass, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NDsEfog5kVkT5NmX1Ya5be
This was referenced Sep 5, 2026
AThraen
added a commit
that referenced
this pull request
Sep 5, 2026
Release review of the 20 commits since v0.6.0. Three documentation defects, one of them actively wrong. WRONG: the active-terminal highlight section claimed the ring's accent "is stashed on Border.Tag at build time so the highlight method doesn't need to look up the VM". The code does the opposite and its own comment says so — RepoRoot is populated asynchronously by GitService and AccentColor changes when it lands, so a cached Tag goes stale and stops matching the sidebar. The Tag is only a fallback. MISSING: three services added this cycle were undocumented — DbGate (#102), PwshLocator (#104), ClaudeConfigGate (now shutdown-only after #111). MISSING: nothing described what actually makes a session active, which is now the most subtle thing in the codebase and was got wrong twice. Added a section covering all three routes and, more importantly, why two of them must come from the page: - WebView2 is an HwndHost, so mouse input on hosted native content raises no WPF routed events at all — a Preview handler on the host Border only ever sees the ring around the terminal (#108). - xterm's onData is not "the user typed": it also carries device-attribute replies, cursor-position reports, OSC colour replies and focus in/out, none of which are distinguishable from typing by inspecting the bytes (#106). Also recorded why the font-load re-fit exists (#113) and why the ClaudeLaunch stagger must stay a flat delay — including that a plain Task.Delay(2000) still logged gate=36339ms after the revert, which is what finally showed the gate was measuring an app-wide stall rather than causing one. Written as a "do not try this again" note with the measurements, since the adaptive version looks obviously better on paper and was attempted three times. Verified while here: every service on disk is now documented, and all nine keybindings in MainWindow match the documented table. Claude-Session: https://claude.ai/code/session_01NDsEfog5kVkT5NmX1Ya5be Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
AThraen
added a commit
that referenced
this pull request
Sep 6, 2026
…ally visible Two waiting states, treated differently on purpose. STARTUP — restore rail A 25-session restore runs ~131s with per-session launch cost swinging 12x (0.6s to 10.9s, measured from crash.log). There is no steady rate to extrapolate from, so an indeterminate spinner reads identically at session 2 and session 22. RestoreRail (2px, under the toolbar) plus a "7 / 25 restoring" pill give the aggregate k-of-N; per-session state stays on the existing placeholder sidebar rows. Restore does NOT block the user — placeholder rows mean you can click into a session the moment it lands — so this stays deliberately out of the way. Nothing modal. The counter advances after the try/catch, not inside it, so a session that fails to restore still moves the rail. Otherwise one bad session strands it short of full, which reads as exactly the hang it is meant to rule out. SHUTDOWN — the overlay was invisible, and that is the real bug Reported as "more like 1 line - thats hard to see. no spinner or anything". The markup looked correct: a Grid sibling with Panel.ZIndex="100" and a Storyboard-rotated arc dead centre. WebView2 is an HwndHost. A native child window is composited by the OS on top of everything WPF renders, and Panel.ZIndex does not enter into it. The overlay was being drawn BEHIND every terminal pane. What reached the user was scrim leaking through the few-pixel gaps between panes — hence a stray line and no spinner, because the spinner sat centred over a pane. The codebase already documents HwndHost for input (#108, three separate call sites). Nobody had written down the output half, which is the more expensive one to rediscover because the code looks right. Fix: collapse TerminalGrid before showing the overlay. Safe — every pane is about to be disposed, and a frozen terminal is worth nothing mid-shutdown. With it visible, it earns being more than a spinner. The board names every session and ticks it off (· pending, ◐ closing, ✓ clean, ⨯ force-disposed), with elapsed time per session, a k/N bar, and a budget bar against ClaudeShutdownBudgetMs. Force-disposed sessions are marked rather than hidden: that is the case a user most wants to see and it used to happen completely silently. ShutdownHint escalates with elapsed time to explain why the wait is long. Explanatory, not jokey — it has to read well on the four-hundredth shutdown, which is the argument against the "be patient" copy I originally sketched. Also here: - Board list scrollbar restyled (scoped to that ScrollViewer). Any setup past ~14 sessions scrolls, so the stock system-coloured bar was on screen for most shutdowns, not an edge case. - Board skipped entirely at zero sessions, so --clean runs don't flash a full-window "0 / 0". - Corrected a stale comment claiming the adaptive config gate is "still used at SHUTDOWN". #117 removed it from both paths. Verified: layout rendered offscreen via RenderTargetBitmap against the real markup extracted from MainWindow.xaml (9 rows and 27 rows, to confirm the card caps and the list scrolls). The airspace fix itself needs a live run — it cannot be proven headlessly, since it is about OS compositing. 304/304 tests pass, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NDsEfog5kVkT5NmX1Ya5be
AThraen
added a commit
that referenced
this pull request
Sep 6, 2026
…ally visible (#120) Two waiting states, treated differently on purpose. STARTUP — restore rail A 25-session restore runs ~131s with per-session launch cost swinging 12x (0.6s to 10.9s, measured from crash.log). There is no steady rate to extrapolate from, so an indeterminate spinner reads identically at session 2 and session 22. RestoreRail (2px, under the toolbar) plus a "7 / 25 restoring" pill give the aggregate k-of-N; per-session state stays on the existing placeholder sidebar rows. Restore does NOT block the user — placeholder rows mean you can click into a session the moment it lands — so this stays deliberately out of the way. Nothing modal. The counter advances after the try/catch, not inside it, so a session that fails to restore still moves the rail. Otherwise one bad session strands it short of full, which reads as exactly the hang it is meant to rule out. SHUTDOWN — the overlay was invisible, and that is the real bug Reported as "more like 1 line - thats hard to see. no spinner or anything". The markup looked correct: a Grid sibling with Panel.ZIndex="100" and a Storyboard-rotated arc dead centre. WebView2 is an HwndHost. A native child window is composited by the OS on top of everything WPF renders, and Panel.ZIndex does not enter into it. The overlay was being drawn BEHIND every terminal pane. What reached the user was scrim leaking through the few-pixel gaps between panes — hence a stray line and no spinner, because the spinner sat centred over a pane. The codebase already documents HwndHost for input (#108, three separate call sites). Nobody had written down the output half, which is the more expensive one to rediscover because the code looks right. Fix: collapse TerminalGrid before showing the overlay. Safe — every pane is about to be disposed, and a frozen terminal is worth nothing mid-shutdown. With it visible, it earns being more than a spinner. The board names every session and ticks it off (· pending, ◐ closing, ✓ clean, ⨯ force-disposed), with elapsed time per session, a k/N bar, and a budget bar against ClaudeShutdownBudgetMs. Force-disposed sessions are marked rather than hidden: that is the case a user most wants to see and it used to happen completely silently. ShutdownHint escalates with elapsed time to explain why the wait is long. Explanatory, not jokey — it has to read well on the four-hundredth shutdown, which is the argument against the "be patient" copy I originally sketched. Also here: - Board list scrollbar restyled (scoped to that ScrollViewer). Any setup past ~14 sessions scrolls, so the stock system-coloured bar was on screen for most shutdowns, not an edge case. - Board skipped entirely at zero sessions, so --clean runs don't flash a full-window "0 / 0". - Corrected a stale comment claiming the adaptive config gate is "still used at SHUTDOWN". #117 removed it from both paths. Verified: layout rendered offscreen via RenderTargetBitmap against the real markup extracted from MainWindow.xaml (9 rows and 27 rows, to confirm the card caps and the list scrolls). The airspace fix itself needs a live run — it cannot be proven headlessly, since it is about OS compositing. 304/304 tests pass, 0 warnings. Claude-Session: https://claude.ai/code/session_01NDsEfog5kVkT5NmX1Ya5be Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 6, 2026
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.
Clicking a terminal has never made it the active session — including after #93, which was supposed to fix exactly that.
Why
#93 added a
PreviewMouseLeftButtonDownhandler on the pane's hostBorder. But WebView2 is anHwndHost: mouse input landing on hosted native content does not raise WPF routed events at all, tunnelling ones included. That handler only ever fired for the ~2px ring around the terminal, never for a click in it.So #93 worked for the sidebar path, and — after #106 — for typing. Never for clicking. Reported as "still won't switch tabs in the left menu when I click and put focus on another window."
Fix
The click can only be observed from inside the page.
terminal-init.jsnow posts anactivatemessage onmousedown(throttled to 300ms, since promotion is idempotent),TerminalBridgeraisesPaneActivated, andMainViewModelpromotes on that and onKeyboardInputthrough one shared handler.The character offset, same PR
Also reported: typing started one character into Claude's placeholder, leaving a stray leading
TfromTry "how do I log an error?".The page-side handler now also calls
fitAddon.fit(). The grid rebuild that used to run on every activation incidentally forced a layout pass and therefore a re-fit; #105 skips that rebuild when nothing visible changes, so the re-fit has to be explicit now. Without it xterm's column count drifts from what the PTY was told and redraws land a column off.That's the second thing #105 turned out to be silently propping up — worth weighing whether it earns its keep (see below).
Also
Corrected the WPF-side comment, which claimed that handler covered clicks in the pane. It never did.
activatepresent in publishedAssets/terminal-init.jsWorth discussing: is #105 still worth it?
Its benefit — avoiding WebView2 reattachment on focus change — was never measured. The 89%-of-restore cost turned out to be the config gate (#107), not the grid rebuild. Meanwhile it has now been implicated in two regressions: the lost re-fit above, and it required a non-obvious signature + version counter to avoid breaking pane paging and restarted-session rendering.
Reverting it is defensible. Not doing that here, since this PR fixes the user-visible symptom either way.
🤖 Generated with Claude Code
https://claude.ai/code/session_01NDsEfog5kVkT5NmX1Ya5be