Skip to content

fix(terminal): make clicking a pane activate it, from the page side - #108

Merged
AThraen merged 1 commit into
mainfrom
fix/pane-click-activates
Aug 29, 2026
Merged

fix(terminal): make clicking a pane activate it, from the page side#108
AThraen merged 1 commit into
mainfrom
fix/pane-click-activates

Conversation

@AThraen

@AThraen AThraen commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Clicking a terminal has never made it the active session — including after #93, which was supposed to fix exactly that.

Why

#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 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.js now posts an activate message on mousedown (throttled to 300ms, since promotion is idempotent), TerminalBridge raises PaneActivated, and MainViewModel promotes on that and on KeyboardInput through one shared handler.

The character offset, same PR

Also reported: typing started one character into Claude's placeholder, leaving a stray leading T from Try "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.

Check Result
Unit tests 312/312
App + Tests build 0 errors, 0 warnings
activate present in published Assets/terminal-init.js

Worth 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

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
@AThraen
AThraen merged commit c4f3985 into main Aug 29, 2026
1 check passed
@AThraen
AThraen deleted the fix/pane-click-activates branch August 29, 2026 19:14
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>
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