Skip to content

feat(tui): scrollable sidebar panes and a corrected attention model - #137

Merged
artyomsv merged 25 commits into
masterfrom
feature/sidebar-scroll-and-attention-model
Aug 9, 2026
Merged

feat(tui): scrollable sidebar panes and a corrected attention model#137
artyomsv merged 25 commits into
masterfrom
feature/sidebar-scroll-and-attention-model

Conversation

@artyomsv

@artyomsv artyomsv commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Summary

  • A working pane no longer reads as "blocked on you". Notification/PermissionRequest park with a match-all trigger while the resume edge (PostToolUse) is narrowed to AskUserQuestion|ExitPlanMode, so approving a Bash/Edit/Write prompt fires no hook at all. workPark no longer clears turnActive — the deleted assignment was a no-op exactly when it was right (an idle-wait nudge, where Stop already cleared it) and wrong exactly when it wasn't (a permission prompt mid-turn).
  • The sidebar's PANES section scrolls. PROJECTS stays pinned; the body windows at an offset with ⋯ N above / ⋯ N below markers, driven by the mouse wheel, and a pane reached from the palette, MCP, the notification sidebar or the attention queue scrolls into view.
  • The attention model is consistent at all three levels. A parked pane now marks its tab (tabBlocked, amber); focus no longer destroys the mark, but the row hides it for the pane you're in; answering — a real keystroke or paste — clears it everywhere; and pinnedAttention, implemented since the context menu shipped, is finally rendered ().
  • Right-clicking a sidebar pane row opens its context menu, focusing the pane first so all ten items act on the pane you clicked.
  • Sidebar tab headings are legible: white or the tab's own colour instead of the same grey as idle pane rows, prefixed with the 1-based ordinal that matches Alt+1..9.

Reported issue "cannot switch panes within the same tab from the sidebar" was retested and withdrawn — no change here.

Notes for the reviewer

Two design decisions reversed during implementation, both recorded in the spec with their reasoning:

  1. Right-click focuses the pane first. The original plan said it must not move focus, and that resolving the owning tab in executeCtxMenuItem was an equivalent fix. It isn't: only the two attention items resolve via findPaneAndTab; the other eight — including Restart and Close — re-resolve through activeTabModel().ActivePaneModel(). Since this is the first entry point that can open the menu on a background-tab pane, "Close pane…" would have armed the confirm for whatever pane was on screen.
  2. Focus hides the blocked glyph; it does not clear the mark. ackFocusedPane runs on every Update, including the 100 ms spinner tick that is running because the pane is working — so clearing on focus destroyed the mark ~100 ms after a park, before it could be seen, while the spinner claimed progress. The mark now survives focus (tab, badge and attention queue stay truthful) and paneRow suppresses the glyph for the focused pane only. Real input to the pane is what clears it: a glance is not an answer, a keystroke is.

executeCtxMenuItem refuses all ten items when the owning tab is no longer active. Entry points focus at open time, which is not the same as still being active at execute time — MCP set_active_pane can move it in between, and nothing closes an open menu when it does.

This is larger than the 400-line guideline (+2.4k). It is one logical concern — the sidebar and the attention model are the same surface, and items 1 and 6 could not be split (dropping the turnActive clear removes the unseen mark, so tabBlocked had to land with it). Happy to restructure into stacked PRs if you'd rather review it in pieces.

Test plan

Automated — all run in Docker via scripts/dev.sh:

  • ./scripts/dev.sh test — 28/28 packages, 1261 tests in internal/tui, output pristine
  • ./scripts/dev.sh vet — clean
  • ./scripts/dev.sh docs-size — within limits

Manual, against a dev build (./quil-dev.exe, confirm [dev] in the status bar):

  1. Park a pane you are watching. Trigger a Bash permission prompt in a focused AI pane. Expect: no on its own row, spinner still running, but its tab goes amber and the project badge counts it blocked. Switch away → appears. Come back and answer it → the mark clears everywhere.
  2. Park a background pane. Expect amber on its sidebar row and an amber tab; focusing it hides the row glyph but keeps the tab; answering clears both.
  3. Overflow the sidebar. Shrink the terminal until the pane list is cut. Expect ⋯ N below; wheel over the strip scrolls the PANES body while PROJECTS stays put; a horizontal/shift wheel does nothing. Resize taller while scrolled to the bottom → the next wheel notch moves the strip.
  4. Right-click a pane row, including one on another tab. Expect the tab to switch, the pane to focus, and the menu to act on that pane. Check "Mark attention" → on the row, surviving focus.
  5. Alt+Shift+A with a blocked pane below the fold → it scrolls into view.
  6. Tab headings read 1:name, 2:name, in white or the tab's colour.

One open question worth watching in use: whether Claude re-fires Notification while a prompt is still outstanding. Nothing in the code establishes it either way, and it changes how long an unanswered pane stays marked in edge cases — noted in the plan's checklist rather than assumed.

artyomsv added 25 commits August 8, 2026 18:45
ackFocusedPane now clears blockedSince/blockedReason alongside
unseen: focusing a parked pane is the acknowledgement, so a pane no
longer sits marked blocked once the user is looking straight at its
prompt. pinnedAttention is untouched — it is the explicit "don't let
me forget" mark and only the context-menu actions own it.

newTestModelWithTabs now takes a panes-per-tab count so a two-pane
tab fixture doesn't need a second builder; existing callers updated.

Also:
- correct the turnActive/unseen/blockedSince field comments, which
  described a park clearing turnActive/setting unseen — no longer
  true since the spinner now survives a park
- assert the resume-after-park test's actual point: PostToolUse
  clears the blocked mark it set, not just that unseen stays clear
pinnedAttention already drove the pane border and the tab label; the
sidebar row -- the one place that lists every pane at once -- never
showed it. Adds a dedicated glyph/style and renders it as the pane's
own state glyph when nothing outranks it, or as a trailing mark
alongside blocked/working when it does, so a pin never disappears
behind a transient state.
The tab heading and the idle pane rows beneath it both painted with
sidebarDimStyle (color 243), so the grouping the PANES section exists
to show was invisible. Headings now default to a bright foreground and
carry the tab's own colour when it has one, applied as foreground
rather than the tab bar's background block, which reads as noise at
22 columns.

Also prefixes each heading with its 1-based ordinal, matching the tab
bar's "N:name" convention and the Alt+1..9 keys, placed before the
name so a narrow strip elides the name and keeps the number.

Collapses paneRow's duplicated doc comment into one, now naming the
pinned-attention glyph it already renders. Extends the paneRow width
sweep with pinned and pinned+state combinations.
Right-clicking a pane row in the project sidebar fell through to a
no-op: the MouseRight branch in the sidebar swallow only handled
project rows. Add a sidebarRowPane case that opens the same pane
context menu the in-pane right-click uses, without focusing the pane
first (a right-click that silently switches tabs would surprise the
user).

That alone was not enough: executeCtxMenuItem resolved its target
through the active tab, so a menu opened on a pane sitting in a
background tab would render and then act on nothing (its FindLeaf
guard never matched). Resolve the owning tab from findPaneAndTab
instead, indexing the pane's own project rather than the active
project's tab slice.
Right-click on a sidebar pane row opened the context menu without
focusing the pane, so a menu opened on a background-tab pane looked
right but acted wrong: eight of the ten menu items (Rename, Restart,
Close, Mute, Notes, Focus, Lazygit, History) are shared with the
keybinding and command-palette paths and resolve their target through
the active tab's active pane internally, not through the clicked
pane's id. Choosing one of those from a background-pane menu silently
acted on whatever pane was on screen instead.

Mirror left-click and focus the pane first via the same
activateSidebarRow/focusSidebarPane path, carrying its switchTab
IPC command forward instead of discarding it. executeCtxMenuItem's
findPaneAndTab-based tab resolution stays as a second line of
defense for any future entry point that opens a menu without
focusing.
The comment above executeCtxMenuItem's findPaneAndTab resolution
still described the reversed pre-focus-first design: it claimed a
background-tab pane menu is possible and that pane-id resolution is
what makes it work. Neither is true after the sidebar entry point
started focusing the pane first.

Worse than stale, the old wording named the wrong safety property:
this block only covers the two paneID-resolving attention items, not
the eight that resolve through the active tab/pane. A reader trusting
the comment could add a non-focusing entry point believing this block
would redirect all ten items, when in fact eight would silently act
on whatever pane is on screen.
The PANES list was hard-truncated at the strip's height with a dim " …",
so with enough tabs and panes the tail was simply unreachable. The
PROJECTS block stays pinned — it is the navigation the sidebar exists
for — and the body now windows at Model.sidebarScroll, marking whatever
it hides above and below with ⋯ rather than just stopping.

The offset lives inside sidebarVisibleRows because the paint and the hit
test both call that one function with the same height; an offset applied
at a render site is the row-drift bug ("click project 3, select project
2") in another form. It clamps a local copy and writes nothing: View has
a value receiver, so a clamp written back from the render path would be
discarded there and kept on the hit-test path, and the two would hold
different offsets. sidebarRows now returns the section boundary rather
than having the windowing re-derive it by matching the heading text.

A strip too short to leave minPaneRows for the body keeps the old tail
cap: eight projects and no panes is worse than a truncated list of both.

Also fixes sidebarTabHeading, which summed marker + ordinal + name and
let one closing truncateCells arbitrate — that cut the ORDINAL, the part
its own comment promises survives and the part that maps to Alt+1..9
("  10:name" came out "  10" at width 4). The ordinal is budgeted first
now, and the heading pads to the full width like every other row.
Wire the wheel event already swallowed over the project sidebar's
strip into scrollSidebar, which moves the PANES body by one notch
(m.cfg.UI.MouseScrollLines, default 3) and clamps against the same
body geometry the paint windows against.

Extract sidebarBodyGeometry so scrollSidebar and sidebarVisibleRows
derive bodyLen/bodyH from one place instead of two, rename the local
that shadowed the max builtin in maxSidebarScrollFor, and comment the
three provably-unreachable totality guards in sidebarVisibleRows.

Also reset sidebarScroll on project switch, so the incoming project's
PANES body always starts at the top rather than carrying over an
offset scrolled deep into the outgoing project's pane list.
switchProject was not the only path that changes the active
project — jumpToPane sets it directly and is the shared choke
point for MCP set_active_pane, notification-sidebar navigate,
pane-history back, and the palette's goToPane. None of them reset
the PANES scroll offset on a project-crossing jump.

Reset it in jumpToPane's project-index loop, gated on the project
actually changing so a same-project jump keeps the user's scroll
position.
Add scrollSidebarToPane, wired into jumpToPane (the shared cross-tab
choke point for MCP set_active_pane, notification jumps, pane-history
back and the palette) and focusSidebarPane (the sidebar's own click
path). It moves the PANES section the minimum distance needed to bring
a pane's row into the visible window, using bodyH-2 as the worst-case
window when picking a new offset so the target can never land under a
scroll marker.

In jumpToPane it runs after the existing project-boundary scroll reset,
not before, so a cross-project jump doesn't have its target thrown away.

In focusSidebarPane the row clicked is always already on screen, so the
call must be a no-op there. Checking that against the same conservative
bodyH-2 span used to pick a new offset is wrong: that span is one row
narrower than what's actually painted whenever only one scroll marker
is showing, so it wrongly judged the last visible row off-screen and
scrolled the sidebar out from under the click that just landed on it.
scrollSidebarToPane instead checks the real painted window
(sidebarVisibleRows) first and only falls back to the conservative span
once it knows a scroll is genuinely needed.
Add the changelog entry for the nine shipped fixes: sidebar scrolling
and pinned-attention markers, tab heading ordinals/colour, amber
blocked tabs, the permission-prompt spinner fix, and the sidebar
right-click context menu.

Correct four documentation defects the implementation exposed:

- hooks-and-sessions.md still said a park stops the spinner and marks
  the pane unseen, in two places. Both are false since the park fix:
  a park now sets blockedSince without touching turnActive, and the
  same paragraph's tabStyle precedence description was stale too
  (missing the blocked tab style entirely).
- projects.md's sidebar layout notes claimed markers line up on both
  project and tab rows with no exception; the tab row's marker now
  gives way below ~5 cells to keep the ordinal.
- Three sidebar.go comments (plus two test-file comments repeating the
  same line) described sidebarRowAt as mapping to rows[y-1]; it maps
  to rows[y], as the function's own doc comment already says.
- docs/features.md's sidebar glyph list still showed the pre-#132
  warning-sign glyph for a blocked pane instead of the current one.

Record the two invariants a future sidebar change must not break: the
PANES section scrolls while PROJECTS stays pinned, and a park must
never clear turnActive again.
WorkEventPark's comments still described the pre-fix model: park
clears the spinner and marks the pane unseen, same as a stop. That
changed when workPark stopped clearing turnActive. Update both
comments to say what the classifier still does (nothing changed here)
and what the consumer now does with the result.
ackFocusedPane runs at the top of every Update, including the shared
100 ms work-spinner tick that is guaranteed to be running because the
pane is working. Clearing blockedSince there set the mark and dropped it
about 100 ms later, so for a pane that was focused when it parked the
sidebar's marker, the amber tab, the project badge's blocked count and
the attention-queue entry were none of them ever observable — and that
is the commonest park there is: the agent asks for permission while you
are sitting in its pane. With a park no longer clearing turnActive, the
pane then went on claiming to be working the whole time it waited.

The state is kept and the presentation gives way instead: paneRow
suppresses the blocked glyph and its reason for the focused pane, so
"you are looking straight at the prompt" costs a glyph rather than a
fact. tabBlocked, counts() and the attention queue keep reading the same
blockedSince, and leaving the pane restores every signal with no hook
edge required. unseen still clears on focus — it is a "you missed
something" flag that looking genuinely answers, where blockedSince is a
fact about the agent.

The wide-glyph sidebar fixture gains an idle pane ahead of its blocked
one: with a single pane in the tab that pane is focused, so its CJK
blocked reason stopped reaching the row and the fixture quietly stopped
exercising a wide-glyph suffix.
scrollSidebar added the notch to the STORED offset, which can
legitimately sit past the current maximum: nothing clamps it when the
geometry changes underneath, and sidebarContentHeight is height-1, so
any vertical resize moves the window height while closing panes moves
the body length. The paint stayed correct — sidebarVisibleRows clamps a
local copy, which is the purity rule — so the symptom was not row drift
but a dead scroll plateau: the next several notches computed from the
stale value, clamped back to the same visible maximum, and the strip did
not move. Measured on a 30-pane project resized from 20 rows to 30: the
offset stuck at 9 for three notches before the fourth moved anything.

Clamping the stored value first makes the first notch move the strip.
The new test changes the terminal height between two wheel events, which
is the case no existing test covered.
jumpToNextBlocked sets activeTab and ActivePane by hand rather than
routing through jumpToPane, so it never reached scrollSidebarToPane and
could land the user on a pane below the fold — on the one navigation
most tied to the attention model, and the one whose premise is that the
sidebar is where you look to see where you landed. A same-project jump
kept whatever offset was there; a cross-project one reset to 0, which is
not the same as bringing the target into view.

Placed after switchProject for the reason jumpToPane records: a
cross-project switch zeroes the offset, so computing it earlier would
throw the work away.
Six statements across the package and three rules files still taught
rules this branch changed, each true when written:

- applyWorkTransition's own doc comment claimed a park sets an unseen
  mark on an unfocused pane. A park keeps turnActive, so the falling
  edge never fires; tabBlocked is what carries a parked background pane.
- PaneModel.blockedSince and tabBlocked both named ackFocusedPane as a
  route to a clear. It no longer is.
- The pane border's "finished/parked/pinned" comment listed a reason
  that can no longer set the flag. The colour is unchanged.
- The context menu's justification for its Clear attention row is
  correct again now that focus does not clear the mark, and says so
  explicitly rather than leaving the reader to re-derive it from the
  glyph vanishing off the focused row.
- The rules files taught the reversed ack rule and did not mention the
  render-side suppression at all.

The sidebar right-click also resolves its row once, before focusing,
instead of once on each side of it. Both resolutions agreed, but only
via two separate arguments about a strip that moves — row order being
independent of the active tab, and the scroll being a no-op for a row
already on screen — and the menu then acts on the pane it names.
features.md's Projects section described the sidebar's pane rows and its
right-click behaviour incompletely, and the changelog claimed a
behaviour that was reversed before merge.

- the amber tab for a parked pane, the numbered tab headings, the ◆
  pinned-attention marker, and the scrolling PANES section with its
  above/below markers and wheel support
- right-clicking a pane row FOCUSES that pane, switching tabs if it
  lives on another one, before the menu opens — a side effect that
  should be read rather than discovered. Also noted in keybindings.md,
  which enumerated right-click on a pane and not on the sidebar row.
- the marker being hidden on the pane you are in while every other level
  keeps showing it, replacing the changelog line that said focus clears
  the mark outright
Approving a Bash/Edit/Write permission prompt fires no hook of its own —
promptToolMatcher is AskUserQuestion|ExitPlanMode, so PostToolUse does
not cover it, and the pane's next event is the turn's Stop. With focus
no longer clearing the mark, an ANSWERED prompt therefore kept its tab
amber, kept counting as blocked rather than working in the project
badge, kept being offered by Alt+Shift+A, and put the marker back the
moment the user switched away.

Real user input routed to a pane is the one signal that distinguishes
answering the prompt from looking at it, so it clears the mark. A glance
is not an answer; a keystroke is.

Wired at the producers that represent a human acting on the pane — the
two handleKey forward paths and both paste paths — rather than at
enqueueInput or forwardInputBytes, which both look like the single choke
point and are not. enqueueInput also carries forwarded wheel notches,
and forwardInputBytes is used by the selection handler to walk the shell
cursor during a mouse drag, emitting arrow-key escapes a permission
prompt would consume as a choice. Scrolling a parked pane or dragging a
selection across it is a glance with a mouse, and a test pins both.

The trigger is input reaching a pane rather than focus, so the
asynchronous paste path answers a pane that is no longer the active one.
The ordered-input queue is untouched.
Eight of the ten pane context-menu items resolve their target through
activeTabModel().ActivePaneModel(), so they are correct only while the
menu's target sits in the active tab. Every entry point focuses the pane
before opening the menu, which establishes that at OPEN time and not at
EXECUTE time: MCP set_active_pane moves the active project and tab, and
the Update-entry guard only closes a menu whose target has vanished, not
one whose active tab moved. Rename then seeded the on-screen pane's name,
Mute toggled it, and Restart/Close armed a confirm for it.

Refuse instead, before the focus sync so nothing is half-applied. The
refusal covers all ten rows, including the two attention items that
resolve paneID directly and could still have acted correctly — one menu
is one surface, and the remedy is a second right-click.
tea.MouseWheelMsg also carries MouseWheelLeft/Right — a trackpad swipe or
shift-scroll emits them — and the handler passed the button to
scrollSidebar as `msg.Button == tea.MouseWheelUp`. Both horizontal
buttons read as "not up" and scrolled the pane list down. Match the two
vertical buttons explicitly, as every other wheel consumer here does,
keeping the swallow outside the switch so a horizontal notch still does
not reach the pane underneath.

Three smaller fixes to the surrounding code:

- Give the "is the body windowed at all" gate one definition
  (sidebarBodyWindowed) instead of three copies across the paint and both
  writers, the same drift hazard sidebarBodyGeometry exists for.
- scrollSidebarToPane did arithmetic on the unclamped stored offset,
  standing next to scrollSidebar's comment on why that must not be done.
  It was safe by a non-obvious proof; now both read the same way.
- Cap the wheel notch at the window height. MouseScrollLines is a
  hand-editable int and nothing bounded it, so off+lines wrapped negative
  at the top of the int range and a wheel-DOWN jumped the strip to the
  top.

Also comma-ok the one unchecked Model assertion in package code, which
panicked inside the Update loop if it ever failed.
blockedTabStyle replaces the tab background wholesale, and the background
is the only thing activeTabStyle and inactiveTabStyle differ by. With two
or more agents parked — the ordinary case the amber mark exists for — the
tab bar stopped saying which tab you were on.

Add an underlined active variant. renderTabBar measures style.Render(name)
to build its click zones, so the variant must render at exactly the same
width: an SGR attribute costs no cells where extra padding would shift
every hit zone after it.
The rule file auto-loads for ctxmenu.go and still described the menu as
though every dispatched item reached its target. Document the refusal and
the distinction that makes it necessary: entry points focus the pane at
OPEN time, which is not the same as its tab still being active at EXECUTE
time, and MCP set_active_pane is the one producer that can move it in
between.
@artyomsv
artyomsv merged commit b1561d5 into master Aug 9, 2026
6 checks passed
@artyomsv
artyomsv deleted the feature/sidebar-scroll-and-attention-model branch August 9, 2026 00:15
artyomsv added a commit that referenced this pull request Aug 9, 2026
## Summary

A tab was painted amber ("blocked on you") while its agent was still
working with three background subagents running. Reported from a live
v1.53.0 session; the pane's own hook spool shows why:

```
UserPromptSubmit          → turn starts
SubagentStart ×4          → 4 background agents
Stop            "Reply ready"
Notification    "Claude is waiting for your input"   ← sets blockedSince
SubagentStop    "cr-rules done"                       → 3 remain
Stop, Stop      "Reply ready"
Notification    "Claude is waiting for your input"   ← re-sets it
```

That `Notification` is Claude's **idle nudge** — its own message says so
— arriving *after* `Stop`, while subagents still run.
`ClassifyWorkEvent` mapped every `Notification` to `WorkEventPark`, so
`blockedSince` was set; `tabBlocked` then painted the tab amber and
`paneRow`'s blocked-outranks-working precedence hid the `◐ ⋯3` the row
should have shown.

**Root cause:** `Notification` is ambiguous. Claude fires it both for a
permission prompt and for an idle nudge, and the classifier collapsed
both into one kind.

## The fix

The hook already knows which is which — `runhook.go` decodes Claude's
`message` field and forwards it as the event Title. So the
disambiguation happens at the **producer**, where the information
exists, and the consumer defaults to the safe answer:

- **Producer** (`internal/claudehook/runhook.go`): positively identifies
the idle nudge from the message and marks it with
`data[hookevents.DataNotifyKind] = NotifyKindIdle`. Nothing is emitted
for a message it does not recognise.
- **Classifier** (`internal/hookevents/workstate.go`):
`hook.claude.Notification` becomes `WorkEventNotify`.
`PermissionRequest` and opencode `permission.ask` stay `WorkEventPark` —
unambiguous, always a real block.
- **Consumer** (`internal/tui/workstate.go`): parks **unless** the event
is positively marked idle *and* the turn is inactive.

**The direction of the match is the point.** Matching upstream English
prose is fragile, so the idle phrase is matched positively and
everything unrecognised is treated as a park. If Claude rewords the
nudge, the regression is the old visible amber tab — not a permission
prompt that never surfaces. The `turnActive` condition is retained as
the second gate so the sequence above still resolves correctly against
an older hook binary that doesn't yet emit the marker (mixed-version
daemon/TUI, and the `off`/`verbose` hook-mode paths).

| Situation | Before | After |
|---|---|---|
| Permission prompt mid-turn | `▲` blocked | `▲` blocked (unchanged) |
| `PermissionRequest` after `Stop` | `▲` blocked | `▲` blocked
(unchanged — unambiguous) |
| Idle nudge after `Stop`, subagents outstanding | `▲` blocked, **tab
amber** | `◐ ⋯N` working, tab clean |
| Idle nudge after `Stop`, nothing outstanding | `▲` blocked | `✓`
unseen (what `Stop` set) |
| Unrecognised `Notification` message, any turn state | `▲` blocked |
`▲` blocked (safe default) |

The wrong state predates #137 — what #137 added was `tabBlocked`, which
made it loud: an idle nudge now repainted the whole tab.

## Review history

The first iteration gated purely on `turnActive`. Both the security and
code-quality reviewers independently found that this trades a **visible,
self-clearing false positive** for a **silent, terminal false
negative**: `turnActive` is false whenever a background subagent raises
a prompt after the main turn's `Stop`, after a reattach
(`resetWorkStateForReattach` zeroes it), or when ring eviction drops the
start edge — and a parked agent emits no further hook to recover the
mark. Notably the production trace contains no `PermissionRequest` at
all, so on that Claude version `Notification` is the sole permission
signal and that gate was the only guard on it. Hence the producer-side
marker and the park-by-default.

Ten findings were fixed in the follow-up commits, including two rule
files left contradicting each other and a replay-safety comment that
understated eviction risk once a second edge began depending on a
preceding start edge.

## Test plan

- `./scripts/dev.sh test` — 28/28 packages
- `./scripts/dev.sh vet` — clean
- `./scripts/dev.sh docs-size` — within limits

New tests, RED before GREEN, covering both directions of the gap:

1. The full production incident replayed, including the `SubagentStop` —
not blocked, still working, `tabBlocked` false.
2. `Notification` mid-turn still blocks (must not regress).
3. `PermissionRequest` after `Stop` still blocks (unambiguous signal
stays ungated).
4. A `Notification` whose message is **not** the idle phrasing, with
`turnActive` false → blocks. This is the case the first iteration
silently dropped.
5. A `Notification` carrying **no marker at all** (older producer) →
blocks. Pins mixed-version safety.
6. Producer-side classification in `runhook.go`, plus the
`ClassifyWorkEvent` table.

Manual, against a dev build (confirm `[dev]` in the status bar):

1. Let an agent's main turn finish while background subagents run, then
leave the pane until Claude's "waiting for your input" nudge fires.
Expect `◐ ⋯N` and no amber tab.
2. Trigger a real Bash permission prompt mid-turn — expect `▲` and an
amber tab exactly as today.
artyomsv added a commit that referenced this pull request Sep 6, 2026
## What

Commits 28 files that were sitting untracked in the working tree, plus 7
updates to notes already tracked.

**`docs/superpowers/` — 17 plan and spec records (2026-08-05 …
2026-08-19)**

The tracked record jumps from `2026-08-01` straight to `2026-08-15`.
Every one of the missing documents describes work that has since
shipped:

| Record | Shipped as |
|---|---|
| worktree-panes stage A + ledger, worktree-owned-panes design | #133
(v1.51.0) |
| worktree-panes stage B | #134 |
| sidebar-width-control, sidebar-worktree-name | #137 |
| sidebar-attention-fixes (+ design) | #137 / #139 |
| keybinding-registry-stage-1, keybinding-presets design | #138 |
| overlay-lifecycle (+ design) | #153 |
| desktop-notifications (+ design) | #154 / #193 |
| keybinding-sequences-presets (+ design) | #164 |
| alt-screen-replay-detection | #173 (issue #172) |

**`.claude/agent-memory/` — 11 new review-agent notes, 7 updated**

Same shape as the 33 notes already tracked and the `chore(memory)`
commits in #206 / #207.

## Why now

Found during a branch and worktree cleanup pass. Two of the memory notes
existed only inside a merged worktree that was about to be deleted.

## Risk

Documentation only. No file under `cmd/` or `internal/` is touched, so
the changelog gate does not apply and the release workflow's denylist
skips this entirely — no version bump.
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