Pin panes created through RunPane by default, closes #479 - #481
Conversation
Every orchestration flow that created a working pane had to follow it with a `panes pin` call, and a missed call buried that pane among dozens of legacy ones. The panes an agent creates through the CLI are exactly the panes that belong in the pinned sidebar, so the default was backwards. The default now lives in the daemon: `parsePaneCreateItem` fills an absent `pinned` with `true`. Putting it there rather than in the wrappers means an orchestrator running a months-old `runpane` still gets pinned panes, and `--from-json` payloads that omit the field do too. The Pane UI create dialogs carry their own `startPinned` preference straight to `createSessionAndWait` and never reach this handler, so interactive creation is untouched. `--no-pinned` is the opt-out for throwaway shells and bulk imports. Both wrappers parse it through a shared resolver: the direct-args path sends `pinned` explicitly so a current CLI pins even against an older daemon, while `--from-json` rewrites items only when a flag is present, leaving a payload's own per-item `pinned` intact. Passing both flags is refused the way `--focus`/`--no-focus` already is. `--pinned` still parses and still sends `true`, so existing scripts keep working and simply say nothing new. `agent-context` carries the change to orchestrators in all three places they read — the happy-path command, the creation rule, and the `panes create` notes — so skills can drop their explicit pin step. Claude-Session: https://claude.ai/code/session_011oCb75DdJNM7i3fRQ8dzTt
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9cd966d65f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| "name": "--pinned", | ||
| "required": false, | ||
| "description": "Create the pane already pinned (the Pane UI's favorite/pin star); does not imply focus." | ||
| "description": "Accepted for backward compatibility and now a no-op; `panes create` already pins by default." |
There was a problem hiding this comment.
Describe --pinned as overriding JSON payloads
When panes create --from-json is given a payload containing pinned: false together with --pinned, both wrappers replace every item's value with true in buildPaneCreateRequest/build_pane_create_request. Calling the flag “now a no-op” is therefore incorrect and can mislead consumers of this agent-facing contract into believing their per-item JSON choices are preserved; document its remaining override behavior instead.
Useful? React with 👍 / 👎.
* Add a before/after capture rig for animation evidence
Judging motion from a diff does not work, and a still screenshot of a 180ms
curve shows nothing. This records it instead: one Playwright context per
animated moment, driven against the Vite dev server with the Electron API
mocked, so both halves of a before/after pair run the same fixtures at the
same viewport and only the motion code differs.
Two details make the clips readable. Chromium's animation clock is dropped to
0.2x through CDP right before the interaction — and only then, so setup still
runs at full speed — which turns a 180ms transition into ~22 frames instead of
four. And each moment records the viewport rectangle its surface occupies once
the motion settles, so `scripts/render-anim-evidence.mjs` can crop the clip to
the thing that actually moved rather than 1280x800 of mostly-still app.
PANE_ANIM_PHASE=before pnpm anim:evidence && pnpm anim:evidence:render before
Not wired into any CI suite; it exists to produce PR evidence on demand.
Claude-Session: https://claude.ai/code/session_01WL3PtNCSp37SzLJQJ5QZk3
* Give Pane a motion vocabulary and a reduced-motion contract
Pane had eight easing tokens and none of them were usable for interface motion:
the `--ease-out` in effects.css is Tailwind's `cubic-bezier(0, 0, 0.2, 1)`,
which is too weak to read as deliberate at the durations a daily-driver dev
tool can afford. Components that wanted something stronger hand-typed their
own — `cubic-bezier(0.16, 1, 0.3, 1)` in InterceptorToast,
`cubic-bezier(0.25, 0.46, 0.45, 0.94)` in the dropdown keyframes.
So: `--ease-out-strong` and `--ease-in-out-strong`, plus a duration scale named
for the moment rather than the number — press, press-release, enter, reveal,
modal — with a comment on the scale saying out loud that these are ceilings and
that the surfaces reached by keyboard hundreds of times a day get none of them.
Both are exposed to Tailwind as `ease-out-strong` and `duration-reveal` etc.
The app also had no `prefers-reduced-motion` handling anywhere — one match in
the whole frontend, in a scroll utility. This adds the block, written the way
the setting is meant to work: fewer and gentler, not zero. Opacity and colour
survive because they carry meaning at no vestibular cost; movement collapses;
spinners and pulses stay because they are how the app says "busy", and deleting
them would remove information rather than motion.
`.pane-press` and `.pane-reveal` land here as the two hooks the following
commits attach to.
Claude-Session: https://claude.ai/code/session_01WL3PtNCSp37SzLJQJ5QZk3
* Shorten the dialog entrance and let the backdrop arrive with it
Every dialog in Pane — New Pane, Settings, About, the permission prompt — came
up on `animate-fadeIn`: 300ms of `scale(0.95)` on the plain built-in `ease-out`,
while the backdrop behind it appeared in a single frame with no transition at
all. Two things were wrong. The panel outlasted its own budget for a surface
this ordinary, and it arrived onto a backdrop that was already fully there, so
the dim read as a flash the dialog then caught up with.
Now both run the same 200ms on `--ease-out-strong`, and the panel starts from
`scale(0.97)` rather than 0.95 — closer in, because the shorter the animation
the less distance it can cover before the end feels abrupt. The pair moving
together is what makes the dialog feel like it came from somewhere instead of
being switched on.
`animate-fadeIn` had no other callers, so it retires with its keyframes.
Claude-Session: https://claude.ai/code/session_01WL3PtNCSp37SzLJQJ5QZk3
* Stop animating the command palette
The command palette is a Modal, so it inherited the dialog entrance — a scale
and fade before you could type — and its result rows cross-faded their
selection over 150ms on every arrow key. Both are the case Emil Kowalski's
frequency table calls out by name: an action taken a hundred times a day gets
no animation, ever, because at that rate motion stops reading as polish and
starts reading as the app being slow. Raycast's palette has no open animation
and that is the correct experience, not an omission.
So the palette opens on the frame the shortcut fires, and the highlight is
wherever the selection is — no easing between the key you pressed and the row
it lands on. Modal gets an `instant` prop for it rather than a one-off, since
this is a property of the surface's frequency and any future keyboard-first
overlay wants the same.
Every other dialog keeps the entrance from the previous commit: they are opened
occasionally, by pointer, and they are what the budget exists for.
Claude-Session: https://claude.ai/code/session_01WL3PtNCSp37SzLJQJ5QZk3
* Halve the sidebar and terminal rail reveal, and turn its curve around
Collapsing the sidebar ran 300ms of `transition-[width]` on
`cubic-bezier(0.4, 0, 0.2, 1)` — an ease-in-out, so it left slowly, and every
frame of it relaid out the whole window including the terminals. That combination
is the worst case for a reveal: the slowest part of the curve is the beginning,
which is the part the user is watching for a response, and the expensive part is
all of it.
180ms on `--ease-out-strong` instead. The curve puts most of the width in the
first 60ms, so the layout has visibly committed to the new shape almost
immediately and the remainder is just the tail settling — which is why this
reads as faster even though it is still an animation. It also cuts the window
of layout churn by 40%.
The immersive-mode terminal rail carried the identical 300ms ease-in-out and the
terminal dock a 200ms one; all three now share `duration-reveal` and the
`.pane-reveal` marker, so they collapse together under reduced motion and there
is one number to change if 180ms turns out to still be too long.
Claude-Session: https://claude.ai/code/session_01WL3PtNCSp37SzLJQJ5QZk3
* Grow menus out of the button that opened them
Every menu in Pane — the sidebar overflow, the panel tab bar's add-tool menu,
the repository actions, the resource popover — scaled up from its own centre,
because that is CSS's default `transform-origin` and nothing had overridden it.
Watch the before clip and the menu looks like it is being placed over the
sidebar; nothing connects it to the `...` it came from.
Each of these menus is already pinned by a specific pair of edges: Dropdown's
`computePosition` sets `top`/`bottom` and `left`/`right` to align the menu with
its trigger, so those edges are exactly where the trigger is. Reading the origin
back out of the position it already computed — rather than threading a second
source of truth through — means the menu scales from the corner it is hinged on,
whichever side of the trigger it opened towards, including the flip to opening
upwards when there is no room below.
With the origin doing the spatial work the translate no longer has to, so it
drops from 8px to 4px, and the whole thing is 160ms on `--ease-out-strong`
rather than 200ms on `cubic-bezier(0.25, 0.46, 0.45, 0.94)` — a menu is
something you are already reaching into, so it should be finished before your
eyes have finished travelling.
Claude-Session: https://claude.ai/code/session_01WL3PtNCSp37SzLJQJ5QZk3
* Stop the menu highlight trailing the pointer
Dropdown rows highlighted on `transition-all duration-200`. Two problems, and
the first is the one you feel: a menu is read by running the pointer down it, so
at 200ms the highlight is still catching up to a row you have already left, and
by the third row the whole menu looks like it is lagging your hand. The second is
that `transition-all` on a hover state animates every property that changed —
including the border and layout properties on the selected variant — off the
compositor, on every row the pointer crosses.
100ms, and only the properties that actually change: colour, background, border
and the inset shadow the hover state adds. The highlight now lands under the
cursor rather than behind it.
Nothing is added here. This entry in the pass is a subtraction, and it is
probably the one that shows up most often in a day of use.
Claude-Session: https://claude.ai/code/session_01WL3PtNCSp37SzLJQJ5QZk3
* Give buttons something to say when you press them
Pane's Button and IconButton had a hover state and a focus ring and nothing at
all for `:active`. Press one and the interface said nothing until whatever the
button does finished happening — which, for the buttons that create a pane or
run git, is not immediate. Confirming that the pointer was heard, before any of
the work starts, is the cheapest perceived-speed win available: nothing about
the app got faster, but the gap the user is waiting through now begins with a
response instead of with silence.
`scale(0.97)`, the subtle end of the range — this is a dev tool, not a toy — and
asymmetric on purpose: 90ms down so the acknowledgement lands under the finger,
160ms back so the release settles rather than snapping. Symmetric press feedback
is the tell that a button was styled rather than felt.
The `transition-all` both base styles used goes with it. It was animating every
property that changed, layout ones included, off the compositor on every hover.
Deliberately scoped to the two button primitives. Sidebar rows and panel tabs
are pressable too, and they are not getting this: they are navigation, hit
dozens of times an hour, and a scale on every pane switch is motion in the one
place this branch is trying to keep still.
Claude-Session: https://claude.ai/code/session_01WL3PtNCSp37SzLJQJ5QZk3
* Let a status pill arrive in the title bar instead of blinking into it
dcouple#472 and dcouple#478 put the pane name in the title bar, with the PR number and merge
readiness hanging off the end of it. Those pills appear the instant a git status
poll comes back — one frame nothing, next frame "dcouple#481 Ready to merge" — which is
the classic teleporting state change: the thing you would most want to notice is
the thing least likely to catch your eye.
A 150ms fade and scale from the end of the pane name fixes that, and the whole
of the care here is in deciding when it plays. A pill that shows up while you
are looking at the pane is news. A pill that is merely present because you just
switched to a pane that already had one is not — and animating that would put
motion on pane switching, which is exactly what this branch exists to keep out
of. So `useArrivedKeys` scopes the comparison to the active pane: change panes
and every pill counts as pre-existing, stay put and only genuinely new keys
animate.
It updates in render rather than an effect on purpose — an effect fires after
paint, which would show the pill at full opacity for a frame and then start it
over from zero. Render-phase state is the supported way to get the class onto
the commit that first paints the element.
Claude-Session: https://claude.ai/code/session_01WL3PtNCSp37SzLJQJ5QZk3
* Lock the motion decisions with a test
The clips in the PR show what the motion looks like; they cannot stop it being
undone. These five assert the decisions themselves, and the ones worth guarding
are the negative ones: the command palette's panel has `animation-name: none`
and its rows a `0s` transition, because "we deliberately did not animate this"
is the sort of thing a later refactor helpfully re-adds.
The rest pin the values to the tokens rather than to numbers typed twice — the
dialog on `modal-enter` at 200ms and `cubic-bezier(0.23, 1, 0.32, 1)`, the menu's
`transform-origin` landing on the corner its position was pinned to, the button
scaling to 0.97 in 90ms and settling back over 160ms — and the last one drives a
real press and a real menu under `prefers-reduced-motion: reduce` to check the
movement is gone and the fade is not.
Claude-Session: https://claude.ai/code/session_01WL3PtNCSp37SzLJQJ5QZk3
* Tune the evidence GIFs so the PR page loads
14fps at a 520px cap with a 64-colour palette: the GIF is the inline preview
in the PR table and a 4MB one is a page nobody scrolls. The MP4 rendered
beside it keeps the full resolution for scrubbing.
Claude-Session: https://claude.ai/code/session_01WL3PtNCSp37SzLJQJ5QZk3
Description
Closes #479.
runpane panes createnow creates the pane pinned. Orchestrators can deletethe
panes pincall that used to follow every create.panes createpanes pinpanes create --pinnedpanes create --no-pinnedThe one thing anyone has to act on: a script that relied on
panes createleaving a pane unpinned now has to say
--no-pinned. Nothing else moves.One consequence of putting the default in the daemon: someone on an older
wrapper gets the new pinning immediately, but cannot type
--no-pinneduntilthey upgrade — their bundled contract has no such flag, so it errors as an
unknown option. That is the intended trade. Pinning by default is the safe
outcome to hand an old client; the opt-out is the part worth waiting for an
upgrade, and
panes unpincovers the gap meanwhile.Where the change actually is: the whole behaviour flip is one default in the
daemon,
main/src/ipc/runpane.ts. Everything else in the diff is regeneratedcontract files and the
agent-contexttext that tells orchestrators the pinstep is gone.
Visual Overview
Why the old default was backwards
Every orchestration flow ended in a second
panes pincall, becausepanes createleft new panes unpinned unless--pinnedwas passed. Miss thatcall and a live working pane sits buried among dozens of legacy ones, silently
— the only way to know was to audit afterwards. One real session created 15+
working panes this way. But the panes an agent creates through the CLI are
exactly the panes that belong in the pinned sidebar, so the default was
pointing the wrong way.
Why the default lives in the daemon, not the wrappers
parsePaneCreateItemfills an absentpinnedwithtrue(
main/src/ipc/runpane.ts). Putting it there rather than in the wrappers is thesubstance of the change, and it is the reason the UI is unaffected.
The two
runpanewrappers — the npm and PyPI CLIs — ship on their own releasecadence, and users pin old versions. Had the default lived only in
localControl.tsandlocal_control.py, an orchestrator running last month'swrapper would keep creating unpinned panes, and the audit this issue exists to
delete would still be necessary. The daemon is the one component guaranteed
current with the app, so putting it there covers old wrappers,
--from-jsonpayloads that omit the field, and any other client on the control socket.
It also draws the line the issue asks for, for free: the Pane UI's create
dialogs carry their own
startPinnedpreference straight intotaskQueue.createSessionAndWaitand never reach this handler. Only CLI anddaemon creation moves.
--no-pinned, and what--pinneddoes now--no-pinnedis declared once incontracts/runpane/contract.jsonandregenerates into the TypeScript contract, the shared contract, the Python
contract, the parser fixture, and
docs/RUNPANE_CLI_CONTRACT.md. Both wrappersparse it through a shared
resolvePinnedOverride:--repo/--name) sendpinnedexplicitly rather thanleaning on the daemon default. That covers the mirror image of the skew above
— a current wrapper talking to an older app still pins.
--from-jsonrewrites items only when a flag is present. A payloadthat sets
pinnedper item keeps its own intent, and the daemon default fillsthe gaps. This extends the existing
--pinnedbehaviour rather than changingit.
--pinned --no-pinnedtogether is refused, the way--focus/--no-focusalready is.
--pinneditself is untouched: it still parses and still sendspinned: true,so the wire shape is unchanged and existing scripts keep working. They simply
stop saying anything new.
agent-contextis what actually retires the pin stepOrchestrator skills read
runpane agent-contextto decide what a create callneeds. Flipping the code without flipping that text would leave every skill
still emitting its follow-up pin. All three places it surfaces now carry the
new default:
--pinnedand notes the new pane is pinnedalready,
panes pincall is unnecessary and names--no-pinnedas the opt-out,panes createdetail notes say the same and mark--pinneda no-op.Testing
Automated — all green
pnpm run test:runpane-contract— passes.checkPanePinParitygained threecases in both the Node and Python halves: a bare create sends
pinned: true,--no-pinnedsendsfalse, and the two flags together are refused. Itspre-existing
--pinnedcase still assertstrue, unchanged.pinned/noPinnedjoined the parser parity projection and a--no-pinnedsample joined
parserSamples, so the two implementations stay locked to eachother.
pnpm run test:runpane-package-smoke,pnpm run check:runpane-package-versions— pass.both create paths in sequence (absent
pinnedreachescreateSessionAndWaitas
startPinned: true, explicitpinned: falseasfalse), the otherasserts the dry-run preview reports the same two. One existing assertion asserted
startPinned: undefinedon a default create and now assertstrue— that assertion isthe behaviour change.
pnpm typecheck,pnpm lint,pnpm run build:main,pnpm run build:frontend— pass.Still needs a human, because none of the above touches a running app
runpane panes create --repo <repo> --name test-default --agent claude --source agent --no-focus --yes --json— the new pane appears in thepinned sidebar with no second command.
--no-pinned— the pane is created and stays out ofthe pinned set.
--pinned— still accepted, still pins, no warning.it must still land unpinned. This is the one that proves the UI path was
not caught up in the change.
runpane agent-contextreads correctly to an orchestrator, i.e. an agentfollowing it would not add a pin step.
Additional Notes
One line that reads like a contradiction but isn't:
main/src/services/skillCacheManager.tstells the orchestrator skill to"include active unpinned panes; pinning is only a UI favorite signal, not an
activity signal." That stays true and stays put — UI-created and
--no-pinnedpanes are still unpinned, so a status sweep that trusts the pin star would
still miss them.
If you reproduce the test run locally: the main Vitest suite needs
pnpm rebuild better-sqlite3-multiple-ciphersunder Node 22 first, because theworktree's copy is built for Electron's ABI. This worktree was restored with
pnpm run electron:rebuildafterwards.