Skip to content

feat: hook-driven task state, session drag between panes, and a terminal that survives a flood - #341

Merged
attson merged 26 commits into
mainfrom
feat/session-drag-and-state-fixes
Aug 16, 2026
Merged

feat: hook-driven task state, session drag between panes, and a terminal that survives a flood#341
attson merged 26 commits into
mainfrom
feat/session-drag-and-state-fixes

Conversation

@attson

@attson attson commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Summary

  • AI session state is reported, not guessed. claude and codex render inline and run as one long shell command, so OSC 133 never marks a turn boundary and the silence heuristic had to infer one — it cannot tell "answered you, now waiting" from "thinking, or running a quiet tool", so the state flipped while the model worked and every consumer flickered with it. Their hook systems already say which it is; those events now drive task_state, and a session latches onto that source the first time one arrives (the silence timer stops arming, so the two can never disagree). Covers both CLIs, installing into ~/.claude/settings.json and ~/.codex/hooks.json.
  • Sessions can be dragged. A sidebar row dropped on a pane splits it and lands beside it; a pane's grip dropped on a sibling swaps them, or on the tab strip becomes its own tab. Same action in both context menus. Replaces the session picker modal, which is deleted along with its two shortcuts.
  • A flood no longer knocks the terminal offline. yes used to disconnect the local terminal every few seconds: the client fell behind, the fan-out dropped it, it reconnected, replayed 4 MiB, and fell behind again. Replay is capped at the tail the client can actually keep, and a client that falls behind is resynced in place instead of dropped. Keystrokes now reach the PTY during a replay, so Ctrl-C stops a runaway command.
  • Ordering is stable and running-first. Sorting keyed off last_output_at, so two streaming AI sessions traded places several times a second. It now keys off interaction timestamps that stay frozen for a whole run, with output freshness only as a minute-quantised tiebreak. running leads the urgency order in the sidebar and the widget.
  • Smaller things: the sidebar row gained a fixed tail column (kind badge + last output) and lost its per-row unread controls, which restated a fact shown three other ways; the desk widget colours its escalation highlight by reason and stops unfolding for non-events; the renderer log bridge got a boot heartbeat so an empty ui-* section means something.

Test plan

  • Start a claude session; confirm the sidebar icon stays running through a long turn instead of flipping to waiting_input on every pause
  • Same for a codex session (~/.codex/hooks.json is written on launch)
  • Run yes; confirm no "reconnecting" badge, no reappearing replay progress bar, and that Ctrl-C stops it
  • Drag a sidebar session onto a pane (splits), a pane grip onto a sibling (swaps), and onto the tab strip (own tab)
  • Right-click a sidebar row and a pane: "独立标签页" appears only for a session sharing a tab
  • Two AI sessions streaming at once: rows hold their order
  • ~/Library/Logs/AT-Term/desktop.log contains [ui-boot] renderer ready after launch

attson and others added 26 commits August 16, 2026 00:13
The heuristic that infers waiting_input from output silence had asymmetric
thresholds. Holding `running` required 256 bytes inside a one-second burst
window, but restoring `running` from heuristic waiting only required 256 bytes
total, with no window at all.

Any session emitting a thin, steady drip therefore fell between the two rules:
too quiet to hold running, so it flipped to waiting_input after 5s, yet its
bytes accumulated without decay until they crossed the same threshold and
forced it back. Even a bare cursor blink (~10 B/s) oscillated on a ~30s period,
for the whole life of an idle session, and the sidebar and desk widget blinked
between "live" and "now" along with it.

Give the restore side the same burst window the running side uses, so both ask
one question: did enough bytes arrive inside one window? Route every reset
through a helper so the counter and its window cannot drift apart again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`sessionActivityAt` folded `last_output_at` into the sort key, so two AI
sessions streaming at once traded the lead several times a second and the
sidebar reordered under the pointer.

Split the two ideas apart. `sessionInteractionAt` keys off timestamps that stay
frozen for a whole run — command start/end, attention, session start — so rows
hold still while sessions work. `sessionOutputBucket` keeps output freshness as
a last-resort tiebreak, quantised to the minute so a session with no shell
integration can still surface without reintroducing the churn.

Display is untouched: LastOutputIndicator reads `last_output_at` directly, so
"3m ago" still ticks by the second.

Also drops useSessions' hand-copied comparator in favour of the shared one, so
the next change to the ordering cannot land in only one of them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…olding for non-events

The highlight marking a freshly-escalated session used the generic accent blue
at the same 0.14 alpha as :hover, so it read as "the pointer is resting here"
rather than "this one is asking for you" — and the blue collided with the live
indicator right beside it. Take the colour from the reason instead (waiting =
amber, failed = red) and pulse briefly on entry: the row only has to earn one
glance, so the blink ends while the static tint stays for the rest of the peek.

The auto-peek itself unfolded the widget for three things that were not events:

  - The first snapshot after connecting diffed against an empty state, so every
    session that was already waiting counted as newly escalated.
  - The diff ran over the truncated visible rows, so a session scrolling past
    maxRows and back looked like a fresh escalation.
  - A session whose state flapped re-escalated on every cycle.

Seed the baseline from the first snapshot without peeking, diff over a new
`attentionIds` covering every session rather than the rendered slice, and hold a
per-session cooldown so a flapping session raises its hand at most once a
minute.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…read controls

The last-output stamp trailed the cwd while a separate 32px gutter sat reserved
on the right, so the timestamp neither aligned nor filled the space, and rows
without one left a ragged edge. Move it into a tail column shaped like the desk
widget's — kind badge on the title line, timestamp on the cwd line, right
aligned — with a min-width so an empty tail still holds the column open. It is
a min rather than a fixed width because a fixed one takes its slack out of a
title that is already ellipsising.

The close x moves into that column's badge slot and the badge yields while it
shows, so the affordance costs no layout width and overlaps nothing. It is
absolute again, but not the way that got reverted before: it no longer floats
over the title line, and the permanent gutter it needed is gone.

Also removes the per-row unread dot and mark-read control. Unread already
surfaces three other ways — the state icon becomes a star for the two attention
states, unread rows sort to the top of their group, and the group header
carries a count plus mark-all — and unread clears itself the moment a client
attaches, so the manual per-row dismissal was only ever a shortcut. It cost
~22px of a ~224px row to restate a fact shown elsewhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The picker existed to answer one question — which existing session should fill
this new pane — and it answered it with a full-screen modal listing every
session, reached only by a two-modifier shortcut. Dragging a row out of the
sidebar covers the same ground without leaving the layout, so the modal goes.

Takes the whole chain with it: the two split-and-pick shortcuts (freeing
Cmd+Alt+N), the SplitMode parameter that only existed to branch between "spawn"
and "pick", lib/sessions.ts (whose sole consumer was the dialog), and the six
strings none of the survivors reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…b strip

Replaces the picker with direct manipulation. A sidebar row dropped on a pane
splits it and lands beside it; a pane's grip dropped on a sibling swaps the two;
dropped on the tab strip it becomes its own tab. The same action is reachable
from both context menus for anyone who would rather not drag.

Three things were load-bearing and are worth recording:

The payload rides a private MIME type, never text/plain — a pane is an xterm
whose hidden textarea accepts native text drops, so a text/plain payload that
slipped past the handler would be typed into the terminal. The id itself is
stashed in a module variable at dragstart rather than read back from
dataTransfer: WebKit keeps `types` readable, so the drop target lights up, while
returning nothing from getData for a custom type, which made every drop a
silent no-op in the desktop app.

Panes move whole rather than being rebuilt from an id. The `remote` flag decides
which endpoint a pane attaches to and which list resolves its title, so a
locally-spawned shell re-minted as remote resolves in neither and renders empty.

dragover must preventDefault on every event, not just the first: one
un-prevented dragover and the browser stops delivering drop to that element
entirely. Both drop targets carry a regression test that fires three in a row.

The grip cannot be the terminal itself — xterm needs the mouse for text
selection — so it lives in the pane's control cluster, which is
pointer-events:none, and opts back in explicitly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every one of the renderer's ~84 log call sites sits on a failure branch, so a
log file with no `ui-*` records is ambiguous: it reads exactly the same whether
the run went cleanly or the bridge to the Go logger stopped working. Chasing a
drag-and-drop bug through an empty log section cost a detour into an HTTP-based
side channel before that ambiguity was noticed.

Emit one INFO record per boot, after mount, on the happy path. An empty section
now means something: the bridge is down.

The bridge itself turned out to be intact — binding generated, Go side covered
by tests, level parsing case-insensitive, flush handlers installed first thing
in bootstrapApp. Tests pin the two properties that would silently break it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AI sessions have no authoritative task_state today: claude and codex render
inline and run as one long shell command, so OSC 133 never reports a boundary
and the silence heuristic has to guess. Silence cannot tell "answered you, now
waiting" apart from "thinking, or running a quiet tool", so the state flips
while the model works and every consumer flickers with it.

The authoritative signal already arrives — hookinstall wires five Claude Code
hooks into atterm-hook and the desktop terminates them — but the events only
feed Feishu cards. This design routes them into the session model instead, with
the silence heuristic latched off per session once hooks prove they work, and
extends the same path to codex (whose hook system landed with near-identical
event names; verified against codex-cli 0.146.0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An AI session's task_state has no authoritative source today: claude and codex
render inline and run as one long shell command, so OSC 133 never reports a
boundary between turns, and the silence heuristic that fills the gap cannot
tell "answered you, now waiting" apart from "thinking, or running a quiet
tool". The state therefore flips while the model works.

Accept the state the client already reports through its hooks, and latch the
session onto that source the first time one arrives: the silence timer stops
arming for good, so the two can never disagree. OSC 133 D clears the latch —
the CLI has exited and its hooks left with it, so the shell underneath goes
back to the heuristic.

Events are ignored unless the session is classified ai, since a hook can land
a moment after the CLI exits and must not move the shell that replaced it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hook ingress already terminates POSTs from atterm-hook and fans them out to
two Feishu consumers. Add a third, narrow one for task state: HookServer gains a
TaskStateSink, relayHost implements it by resolving the session id against the
local registry, and startFeishu wires the two together — the listener starts
unconditionally, so this works whether or not a Feishu binding exists.

The state path deliberately runs before the adapter lookup. The adapter registry
only holds agents that can render a card (claude-code today), while state
routing knows both agents on its own; behind the lookup, every codex event was
dropped. A test covers exactly that.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… explicitly

Codex's hook system covers the same turn boundaries claude's does, so the same
binary can report task state for both. Write ~/.codex/hooks.json alongside the
claude settings, merging rather than replacing (the file is the user's, and
codex loads every entry in it) and replacing our own entry from a previous run
so a version bump does not stack a second line pointing at a deleted binary.
User-level only: hooks configured in a repo-local .codex/config.toml do not fire
in interactive sessions (openai/codex#17532).

A codex failure only warns — plenty of users have one CLI and not the other.

Both installers now pass --agent explicitly, so the receiving end never infers
which CLI called it. That changed the command string, which in turn needed the
health check to compare against the same builder (or it would report a
permanently unhealthy install that repairing could not fix) and to stat only the
binary rather than the whole command line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ve finished

Grouping by host ignored task_state entirely: rows were ordered purely by their
interaction stamp, so a session that finished five minutes ago sat above one
still running, and the two grouping modes disagreed about what the top of the
list meant. Use the same comparator the state groups use — urgency, then
unread, then the stamp.

Within a state the ordering is unchanged, so two AI sessions running at once
still hold their places instead of trading them as they stream.

Also fixes the byState doc comment, which claimed the opposite of what the code
did (it said unread was not promoted there, while byHost promoted it; both were
backwards).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rsing

The replay guard exists to swallow the replies xterm generates on its own while
parsing escape sequences inside replayed scrollback — cursor position reports,
device attributes — which would otherwise land in the shell as typed garbage.
It was implemented as "drop all input during replay", which cannot tell those
replies apart from the user's own keys.

The two cases coincide at the worst possible moment: a command flooding the
scrollback is both why the replay is 4 MiB long and why the user is reaching for
Ctrl-C. With every keystroke dropped for the duration, closing the tab was the
only way to stop a runaway `yes`.

Mark keyboard-originated data via attachCustomKeyEventHandler, which xterm runs
for every key event before emitting the data, and let those bypass the guard.
Terminal-generated replies still get suppressed exactly as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A subscriber whose queue fills before its snapshot finishes is closed without a
word: it never joins s.subs, never becomes driver, and the client sees only a
socket close. From the outside that reads as "the replay progress bar hangs and
my keystrokes do nothing", with nothing in the log to attribute it to.

Log both drop points at INFO, with the phase and how far the replay got, so the
next occurrence can be told apart from the other reasons a replay can appear
stuck.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fan-out drops a client whose queue is full and expects it to reconnect with
ATTACH(since_seq). That is cheap while the scrollback still covers the gap — but
a flood wraps the 4 MiB ring in seconds, so the reconnect replays the whole
buffer, falls behind again, and is dropped again. From the outside that reads as
a replay progress bar reappearing on its own, with no tab switch and no
re-attach, which is exactly what it was mistaken for.

The earlier drop logging covered the two replay-phase paths; this is the one
that actually fires under a flood, and it was silent.

A test pins the drop itself so the loop's first half stays proven rather than
argued.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ring holds 4 MiB and every attach replayed all of it, but the client keeps
20000 lines and discards the rest as it parses. For line-dense output — yes(1)
writes two bytes per line — that is 99% of the replay parsed and thrown away.

The waste is not just idle: parsing megabytes is what pushes a slow client past
its queue depth, at which point the live fan-out drops it, it reconnects, the
ring has wrapped, and it replays the whole buffer again. That loop is what put a
replay progress bar on screen with no tab switch and no re-attach.

Cap the replay at its newest 512 KiB — roughly the 20000 lines the client could
have kept, so nothing visible is lost — and mark the view torn when the cap
bites, exactly as an evicted-bytes gap already does.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pane

openRemoteAsTab defaults remote=true, which is safe only because of an
invariant nobody enforces: every local session is placed into a pane in the same
step that spawns it, and every detachOnly caller re-places it immediately, so a
session that is nowhere on screen is necessarily remote. Clicking a local
session in the sidebar therefore always hits the focus-existing-pane branch and
never this one.

Document that, and stop relying on it. The local session list is authoritative
about locality, so resolvePaneRemote trusts it over the caller's request and
reports the override. Without this, the day the invariant breaks a local session
would attach through the relay endpoint — which does not have it — and render as
an empty pane with nothing in the log to explain why.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A client whose socket stops draining is closed after clientWriteWait, which is
what the user sees as a "reconnecting" badge. The renderer stalls exactly that
way when a flood of output has its main thread stuck parsing, so this fires on a
purely local session with no relay configured — a confusing thing to see, and
the only record of it was a debugf invisible at the default INFO level.

Both writer paths now say so at INFO, with the session and the frame type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every terminal in the desktop app is a websocket client of the app's own
in-process relay on 127.0.0.1 — that is the only path the frontend has to a
PTY, with or without a remote relay configured. So the write timeout meant for
detecting a vanished network peer also governs the local renderer.

It misjudges it. On loopback there is no partition to detect: a socket that
stops draining means the renderer is busy, typically parsing a burst of output,
and it will come back. Tearing the connection down makes that strictly worse —
the client reconnects, replays, and falls behind again, which reads as a purely
local terminal that keeps announcing "reconnecting".

Give loopback clients a far longer bound and leave remote ones at ten seconds.
Liveness is still covered by the ping loop, which fails long before either.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A disconnect currently produces a "reconnecting" badge and nothing else. The
relay discarded the close status when its read loop ended, and the client
ignored the CloseEvent entirely, so a local terminal dropping its connection
left no way to tell the relay hanging up apart from the renderer's own
websocket giving up — which are opposite problems with opposite fixes.

Log the close code and reason from both sides. The relay's line lands in the
desktop log file; the renderer's is there for the console when the Wails
binding is unavailable, as it is under wails dev.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The live fan-out dropped a subscriber whose queue was full and relied on it to
reconnect. Under a flood that contract inverts: the client reconnects, replays,
falls behind again, and comes straight back — so a purely local terminal
announced "reconnecting" for as long as the output lasted, and each round
re-parsed a replay it was already too slow to keep up with.

A terminal cannot show every byte of a firehose and nobody can read one. Throw
away the backlog the client has not caught up on and queue a coherent restart
instead: the same reset marker a truncated replay sends, then the tail of the
scrollback. The client stays attached, keeps the driver, and sees a jump rather
than a disconnect. The log line is throttled, since a flood can overflow many
times a second.

Also renames the websocket close reason from "session ended" to "subscription
ended". The session was alive throughout; the old wording sent this
investigation looking for a session that had ended.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The list now answers "what is moving right now" before "what is asking for
me". Both readings are defensible — a session waiting on you is arguably the
one that needs attention — and this is the one the user asked for, with the
consequence that a session waiting for input sits below a shell that is merely
producing output.

TaskGroupedList kept its own copy of the order for the state-group headers, so
the group order and the row order could disagree. It reads SESSION_URGENCY now.

The desk widget shares the same comparator and follows automatically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The widget is a one-glance summary of the same list, so the sprite claiming
"someone is waiting for you" while the sidebar's top row is a running session
would be worse than either order on its own.

Only the face changes. Which row gets highlighted, and whether a collapsed
widget unfolds itself, still key off waiting/failed: those are events that call
for the user, which is a different question from what the list is mostly doing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
It was written on a wrong diagnosis. The reconnect loop on a local terminal
looked like a write timeout, but the log showed zero of those: the client was
being shed by the live fan-out, which the resync change fixed at the source.

Keeping it would not be free. All three timeouts in the writer share one bound,
including the keepalive ping, so the split also stretched liveness detection for
a local client from ten seconds to five minutes. That is a real cost for a case
we no longer expect — a renderer stalling ten seconds on a replay that is now
capped at 512 KiB.

The INFO log on the write-timeout close stays. If it ever does fire, we will
have evidence instead of a theory, and can put this back with numbers.

Also renames fanout_drop_test.go, which no longer tests a drop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@attson
attson merged commit 99ee540 into main Aug 16, 2026
7 checks passed
@attson
attson deleted the feat/session-drag-and-state-fixes branch August 16, 2026 04:13
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