Skip to content

C11-245: offer a rare tip that teaches the tab rail - #474

Merged
BenevolentFutures merged 8 commits into
mainfrom
c11-245-rail-tip
Oct 1, 2026
Merged

BenevolentFutures merged 8 commits into
mainfrom
c11-245-rail-tip

Conversation

@BenevolentFutures

@BenevolentFutures BenevolentFutures commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

What

A rare, non-modal tip under the count cell when an area's tab bar overflows often. It teaches the tab list the number already opens, and Tab Layout: Rail, with a small preview of the rail.

Try Rail sets Tab Layout to Rail for every area and opens this area's rail. The popover stays up and becomes Undo. Undo puts Tabs back everywhere and is not a dismissal. Don't show again is the only dismissal. Leaving the tip alone waits 30 days.

Bonsplit reports when a strip is scrolling or folded, and the count cell's view, so the tip can anchor there. That hook is bonsplit#10, merged to bonsplit main at d20db06827eb94c36b3d4c88bb9f89051b0e2ad6. That merge commit contains bonsplit#9 (05e158a) and the count-cell anchor. This PR pins that SHA.

Why

Operators who regularly have more tabs than the bar can show never meet the rail. The tip is the introduction. It stays rare, it stays on this Mac, and Undo means trying Rail is not a permanent choice.

How validated

The trigger (overflow-day recording, 4 days inside 14, 30-day spacing, dismissal, Rail hiding the tip without dismissing it, Undo leaving lastOffered in place, and a one-shot c11.tabRailTip.forceOffer that skips those waits) is a pure model with an injected calendar and defaults store. TabRailTipPolicyTests in c11LogicTests exercises those paths. Day keys use a Gregorian calendar in the current time zone.

The app records a sustained overflow through recordSustainedOverflow, with one 2 second timer per area. An area that is still overflowing when the day changes is recorded, including one that started after today's mark. A defaults write of forceOffer is read at the next tab change, window switch, or app switch.

No local xcodebuild and no app launch. CI is the compile gate and runs the logic tests.

scripts/sync-installed-skills.sh c11 was not run. The skill source has the new sentence. The installed copy in ~/.claude/skills/c11/ stays stale until that script is run.

Visual check

  1. On an overflowing strip, and on the solid folded block, the popover sits under the count cell.
  2. Try Rail switches every area to Rail, opens this area's rail, and the popover becomes Undo, including after the bar rebuilds.
  3. Undo restores Tabs everywhere. The tip is not dismissed. It does not come back until the normal 4-in-14 and 30-day rule says so.
  4. Show tab list steps the tip aside, then brings it back with "That list is the number on the bar."
  5. Don't show again leaves Tabs on and the tip does not return.
  6. Escape on the terminal reaches the shell. When the popover itself is key, Escape ends the offer.
  7. Click the narrow-tier collapsed header block, not only the count cell, and confirm the tap still opens the list.
  8. The tip does not take the keyboard back on later refreshes. Typing still goes to the shell.
  9. Clicking outside ends this offer and does not dismiss the tip forever.
  10. A resize blip under 2 seconds does not record an overflow day.

To raise the tip without waiting 4 overflow days, write c11.tabRailTip.forceOffer. The write takes effect at the next tab change, window switch, or app switch. The next overflowing area offers the tip once, then that flag clears itself. Tab Layout must still be Tabs. Don't show again still blocks it.

defaults write com.stage11.c11.staging c11.tabRailTip.forceOffer -bool true
defaults write com.stage11.c11.nightly c11.tabRailTip.forceOffer -bool true

Reset the recorded days, the last offer, and a dismissal:

defaults delete com.stage11.c11.staging c11.tabRailTip.overflowDays
defaults delete com.stage11.c11.staging c11.tabRailTip.lastOffered
defaults delete com.stage11.c11.staging c11.tabRailTip.dismissed
defaults delete com.stage11.c11.nightly c11.tabRailTip.overflowDays
defaults delete com.stage11.c11.nightly c11.tabRailTip.lastOffered
defaults delete com.stage11.c11.nightly c11.tabRailTip.dismissed

The same keys apply on com.stage11.c11 and on a tagged dev domain com.stage11.c11.debug.<tag> (dashes as dots).

New localization keys

English source is String(localized:defaultValue:). Translations for these keys are in Resources/Localizable.xcstrings.

  • tabRailTip.title
  • tabRailTip.body
  • tabRailTip.bodyAfterList
  • tabRailTip.previewLabel
  • tabRailTip.tryRail
  • tabRailTip.showList
  • tabRailTip.dismiss
  • tabRailTip.undoTitle
  • tabRailTip.undoBody
  • tabRailTip.undo
  • tabRailTip.tabOrdinal (token %lld)
  • tabRailTip.status.working
  • tabRailTip.status.waiting
  • tabRailTip.status.flagged
  • tabRailTip.status.idle
  • tabRailTip.status.cold

Closes C11-245

🤖 Generated with Grok Build

@github-actions github-actions Bot added the drawbridge:review Drawbridge: routed to maintainer review label Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Deep review — C11-245: offer a rare tip that teaches the tab rail

Thank you for this — it's a genuinely careful piece of work, and the PR writeup does
something I wish every PR did: it's explicit about what wasn't done (no local build, the
forceOffer escape hatch spelled out key-by-key, the installed-skill sync left to the
maintainer, each visual-check step enumerated). That candor makes the review faster and more
honest, so let me say up front — this belongs in c11, the policy layer is in excellent shape,
and the notes below are about landing it safely, not about whether it should land.

This pass is against the current head 3ad9f2d0 (the earlier automated comment reviewed a
prior head). Two things worth flagging up top so they don't get re-litigated: the previous
"KVO on dotted defaults keys" finding is gone — this head dropped the per-key KVO entirely
and forceOffer is now picked up via the existing tab/window/app-active refresh paths, which
is the right call. And the local-midnight overflow-credit gap is closed via overflowSince +
recordOpenOverflows. No prompt-injection attempts in the diff or the single (automated)
comment.

Summary

Adds a rare, non-modal popover anchored under an area's tab-count cell that teaches the tab
list and the Rail layout when the bar overflows on 4 of the last 14 local days (30-day
spacing, per-Mac, dismissable, one-shot forceOffer escape hatch). A pure TabRailTipPolicy
owns the trigger math and is well tested against an injected calendar and in-memory store;
TabRailTipCenter (855 lines) owns the AppKit popover, slot bookkeeping, re-anchoring, the
escape monitor, and the focus handoff. The feature rides new bonsplit hooks delivered by a
submodule bump.

Alignment

Solidly in scope. This is operator-facing teaching for the workspace-as-infrastructure model;
it stays on the machine (user defaults in c11's own com.stage11.c11 domain — nothing to
logs, socket, or network) and honors the "unopinionated host" principle with no writes to
tenant config. The skills/c11/SKILL.md edit ships in the same PR, honoring the "skills are
contract" rule — the installed-copy scripts/sync-installed-skills.sh c11 is per-machine and
correctly left to the merging maintainer. The .semitransient popover never spins
runModal(), so it can't wedge main the way the C11-204 browser prompt did. This is a feature
touching deny-listed paths (vendor/**, *.pbxproj), so routing to deep review rather than
the autonomous lane is correct.

Findings

1. [blocker — merge gate] The vendor/bonsplit pin must be confirmed reachable from the
fork's main before merge.
The pointer moves 05e158a5 → d20db068
(vendor/bonsplit), and the whole feature depends on hooks that bump adds —
onTabStripOverflow, onCountCellAnchor, onTabSheetOpenChanged, setRailOpen(_:inPane:),
setTabSheetOpen(_:inPane:), tabs(inPane:), selectedTab(inPane:) (wired in
Sources/Workspace.swift around the bonsplitController.onTabStripOverflow = … block and
called throughout TabRailTipCenter.swift). Your description says d20db068 is the
bonsplit#10 merge already on bonsplit main — if so, good. But per the submodule-safety rule
it must be confirmed: a pin not reachable from the fork's main is orphaned on merge and
leaves the parent unbuildable on a fresh checkout. I can't verify it here — the submodule
isn't checked out in the review sandbox. Gate for the merging human: cd vendor/bonsplit && git fetch origin && git merge-base --is-ancestor d20db06827eb94c36b3d4c88bb9f89051b0e2ad6 origin/main && echo ancestor. Don't merge until that prints ancestor.

2. [major] The entire AppKit half — TabRailTipCenter — has never run. The PR states
there was no local build or app launch, and the "Visual check" list reads as the behavior of
design/tab-bar-qol/prototype-rail-tip.html, not the shipped Swift. Only the pure policy is
exercised by tests. The delicate host logic is all unvalidated against a real build: the
re-anchor handoff when the tabs-strip count cell is replaced by the rail-bar cell
(updateUndo + reanchoring), the close disambiguation in popoverDidClose (see finding 3),
the escape-monitor lifecycle, the claimKeyOnce focus handoff, and frontSlot() across
key/main windows. This is exactly the view/event/focus class the repo's testing policy says
must iterate through a tagged reload — a green CI build only proves it links.
Fix: before merge, ./scripts/reload.sh --tag c11-245 then
./scripts/launch-tagged-automation.sh c11-245 --qa fresh, and walk Try Rail / Undo /
Show-list / Dismiss / re-anchor-on-fold / overflow-in-a-background-area, capturing a
screenshot or two. mergeable_state is currently unstable, so also confirm the required
checks (workflow-guard-tests, remote-daemon-tests, web-typecheck, build) are green.

3. [major — verify on the finding-2 pass] The programmatic-close token can be cleared while
the close is still animating, letting a late popoverDidClose tear down a freshly re-anchored
show.
The generation scheme (showGeneration / programmaticCloseGeneration) is a good
design and it correctly protects the re-anchor case (reanchoring) and the hidePopover
path. But closeProgrammatically(_:) in Sources/TabRailTipCenter.swift records the
generation, calls popover.performClose(nil), then drops the token if popover.isShown is
still true (the if popover.isShown, programmaticCloseGeneration == generation { … = nil }
block). With animates = true (ensurePopover), performClose returns while the close
animation is in flight — if isShown is still true at that instant, the token is cleared
before the delegate callback runs. Then in present(on:), a close-then-reshow onto a
different anchor (the operator switches between two overflowing areas, so updateTeaching → showTeaching → present with no reanchoring) bumps showGeneration and reassigns
shownAnchor to the new windowed anchor. The late popoverDidClose for the old close then
finds programmaticCloseGeneration == nil, userClose == false, reanchoring == false, and
a non-nil shownAnchor.window — and falls through to removeEscapeMonitor() + endOffer(),
killing the show that just opened (and removing the new show's escape monitor). Whether this
actually fires hinges on NSPopover's isShown timing under an animated performClose, which I
can't drive here — so treat it as the thing to prove out during the finding-2 tagged pass.
Suggested fix: use close() (synchronous) for programmatic transitions, set
animates = false, or drop the "clear-if-still-shown" heuristic and rely solely on the
generation comparison — any of those makes popoverDidClose fire predictably within the
token's lifetime.

4. [nit] The escape monitor's closure runs on every keyDown while the popover is shown.
installEscapeMonitor adds a local .keyDown monitor; because the popover is non-modal, the
operator can keep typing into the terminal while it's up, and each keystroke pays the closure's
guards. It's cheap (a few flag/window checks, returns the event in every branch) and removed on
close, so it's nowhere near the forceRefresh()/hitTest() hot paths — but given how
keystroke-sensitive this repo is, worth a conscious confirmation during the finding-2 pass that
typing stays crisp with the tip on screen, and that claimKeyOnce's makeKey() handoff
doesn't itself bounce the semitransient popover closed. No code change expected. (The monitor
is built correctly — gated on event.window === popoverWindow and returning the event in
every branch, so a bare Escape typed into an overflowing area's shell is never swallowed.)

Done right, noting so it isn't second-guessed. All 16 new strings are
String(localized:defaultValue:) at the call site, with the %lld token in
tabRailTip.tabOrdinal preserved across all six locales and fed Int64(ordinal); Text(row.title)
is tab content, not a UI literal, so it's correctly not localized. TabRailTipPolicyTests
verifies real runtime behavior through the store/calendar seams (not source text) and lives
under c11Tests/ while compiling into c11LogicTests, touching no socket/TerminalController
— so it's not the C11-105 hazard and runs on the safe local loop. The overflow-recording math
(2s sustain, one mark per local day, across-midnight credit via overflowSince, newest-30
trim) is coherent and matches the tests. The design/tab-bar-qol/prototype-rail-tip.html
artifact (425 lines) inflates the diff but is a harmless, non-shipped design reference.

Recommendation

Merge after fixes — specifically not before the vendor/bonsplit pin is confirmed an
ancestor of the fork's main (finding 1), and a real tagged-build pass has walked the host
paths with the close/animation race in finding 3 explicitly exercised (findings 2–3). The
design is well reasoned and the policy layer is solid; the remaining risk is concentrated in
the ancestry check and in the fact that the whole AppKit half has never actually run. Happy to
re-review once those are closed.


🌉 Drawbridge deep review (lane 3) — an automated pass; a maintainer has been notified and makes the final call.


🌉 Drawbridge deep review (lane 3) — an automated pass; a maintainer has been notified and makes the final call.

Atin and others added 4 commits October 1, 2026 00:16
Clickable HTML for when a tab bar overflows across days: a tip
under the count cell, a rail preview, and Try Rail, Show tab list,
or Don't show again.

Co-Authored-By: Grok 4.7 <noreply@x.ai>
When a tab bar overflows on 4 days inside 14 and Tab Layout is still Tabs, a popover under the count cell offers the tab list and Rail. Try Rail switches every area to Rail and the popover becomes Undo. Undo puts Tabs back and is not a dismissal. Don't show again is the only dismissal.

The trigger is a pure policy with logic tests. Overflow and the count-cell anchor come from bonsplit cc72cff (PR 10), pinned here until that commit is on bonsplit main.

Co-Authored-By: Grok 4.7 <noreply@x.ai>
c11.tabRailTip.forceOffer skips the 4-day count and the 30-day gap for the next overflowing area, then clears when the tip is shown. Dismissal still blocks it. The skill line also names the three keys that reset the normal rule.

Co-Authored-By: Grok 4.7 <noreply@x.ai>
The tip center runs on the main actor. Escape is returned to the window it was aimed at, key is restored once per offer, and an area that stays overflowing past midnight records the new day through the tested sustain rule. Pin bonsplit to the rebased count-cell branch.

Co-Authored-By: Grok 4.7 <noreply@x.ai>
Atin and others added 4 commits October 1, 2026 00:20
vendor/bonsplit is the merge commit of bonsplit#10 on bonsplit main, which also contains bonsplit#9.

Co-Authored-By: Grok 4.7 <noreply@x.ai>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
An overflow that starts after today's mark still remembers when it started, so the next day can count. forceOffer, dismissal, and the last offer use KVO, and a programmatic popover close is matched to the show it closed.

Co-Authored-By: Grok 4.7 <noreply@x.ai>
KVO never fires for a defaults key that contains dots, so an outside defaults write of forceOffer was ignored. The tip refreshes when the app becomes active instead.

Co-Authored-By: Grok 4.7 <noreply@x.ai>
@BenevolentFutures
BenevolentFutures merged commit dde263f into main Oct 1, 2026
11 checks passed
@BenevolentFutures BenevolentFutures mentioned this pull request Oct 1, 2026
5 tasks
@BenevolentFutures
BenevolentFutures deleted the c11-245-rail-tip branch October 1, 2026 09:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

drawbridge:review Drawbridge: routed to maintainer review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant