Skip to content

Pin panes created through RunPane by default, closes #479 - #481

Merged
parsakhaz merged 1 commit into
mainfrom
pin-panes-by-default-on-create-pane-479
Aug 20, 2026
Merged

Pin panes created through RunPane by default, closes #479#481
parsakhaz merged 1 commit into
mainfrom
pin-panes-by-default-on-create-pane-479

Conversation

@parsakhaz

@parsakhaz parsakhaz commented Aug 20, 2026

Copy link
Copy Markdown
Member

Description

Closes #479.

runpane panes create now creates the pane pinned. Orchestrators can delete
the panes pin call that used to follow every create.

Call Before After
panes create unpinned, then a follow-up panes pin pinned
panes create --pinned pinned pinned — now redundant, still valid
panes create --no-pinned flag did not exist unpinned — the new opt-out
New Pane dialog in the app follows its pin checkbox unchanged

The one thing anyone has to act on: a script that relied on panes create
leaving 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-pinned until
they 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 unpin covers 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 regenerated
contract files and the agent-context text that tells orchestrators the pin
step is gone.

Visual Overview

Before: panes create left the pane unpinned and a second panes pin call had to follow, with a dead-end branch when it was forgotten. After: four clients - current npm wrapper, months-old PyPI wrapper, --from-json payload, and --no-pinned - all cross the runpane control socket into one decision inside the daemon, while the Pane UI dialog reaches createSessionAndWait without crossing it.

Why the old default was backwards

Every orchestration flow ended in a second panes pin call, because
panes create left new panes unpinned unless --pinned was passed. Miss that
call 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

parsePaneCreateItem fills an absent pinned with true
(main/src/ipc/runpane.ts). Putting it there rather than in the wrappers is the
substance of the change, and it is the reason the UI is unaffected.

The two runpane wrappers — the npm and PyPI CLIs — ship on their own release
cadence, and users pin old versions. Had the default lived only in
localControl.ts and local_control.py, an orchestrator running last month's
wrapper 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-json
payloads 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 startPinned preference straight into
taskQueue.createSessionAndWait and never reach this handler. Only CLI and
daemon creation moves.

--no-pinned, and what --pinned does now

--no-pinned is declared once in contracts/runpane/contract.json and
regenerates into the TypeScript contract, the shared contract, the Python
contract, the parser fixture, and docs/RUNPANE_CLI_CONTRACT.md. Both wrappers
parse it through a shared resolvePinnedOverride:

  • Direct args (--repo/--name) send pinned explicitly rather than
    leaning on the daemon default. That covers the mirror image of the skew above
    — a current wrapper talking to an older app still pins.
  • --from-json rewrites items only when a flag is present. A payload
    that sets pinned per item keeps its own intent, and the daemon default fills
    the gaps. This extends the existing --pinned behaviour rather than changing
    it.
  • --pinned --no-pinned together is refused, the way --focus/--no-focus
    already is.

--pinned itself is untouched: it still parses and still sends pinned: true,
so the wire shape is unchanged and existing scripts keep working. They simply
stop saying anything new.

agent-context is what actually retires the pin step

Orchestrator skills read runpane agent-context to decide what a create call
needs. 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:

  • the happy-path command drops --pinned and notes the new pane is pinned
    already,
  • the creation rule says a follow-up panes pin call is unnecessary and names
    --no-pinned as the opt-out,
  • the panes create detail notes say the same and mark --pinned a no-op.

Testing

Automated — all green

  • pnpm run test:runpane-contract — passes. checkPanePinParity gained three
    cases in both the Node and Python halves: a bare create sends pinned: true,
    --no-pinned sends false, and the two flags together are refused. Its
    pre-existing --pinned case still asserts true, unchanged.
    pinned/noPinned joined the parser parity projection and a --no-pinned
    sample joined parserSamples, so the two implementations stay locked to each
    other.
  • pnpm run test:runpane-package-smoke, pnpm run check:runpane-package-versions — pass.
  • Main Vitest: 623 passed across 67 files. Two new daemon tests: one asserts
    both create paths in sequence (absent pinned reaches createSessionAndWait
    as startPinned: true, explicit pinned: false as false), the other
    asserts the dry-run preview reports the same two. One existing assertion asserted startPinned: undefined on a default create and now asserts true — that assertion is
    the 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 the
    pinned sidebar with no second command.
  • The same call with --no-pinned — the pane is created and stays out of
    the pinned set.
  • The same call with --pinned — still accepted, still pins, no warning.
  • Create a pane from the Pane UI's own dialog with the pin checkbox off —
    it must still land unpinned. This is the one that proves the UI path was
    not caught up in the change.
  • runpane agent-context reads correctly to an orchestrator, i.e. an agent
    following it would not add a pin step.

Additional Notes

One line that reads like a contradiction but isn't:
main/src/services/skillCacheManager.ts tells 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-pinned
panes 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-ciphers under Node 22 first, because the
worktree's copy is built for Electron's ABI. This worktree was restored with
pnpm run electron:rebuild afterwards.

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
@parsakhaz
parsakhaz merged commit 46aee31 into main Aug 20, 2026
20 checks passed
@parsakhaz parsakhaz changed the title Pin panes created through RunPane by default, so orchestrators can drop their pin step Pin panes created through RunPane by default, closes #479 Aug 20, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines 4828 to +4830
"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."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

pull Bot pushed a commit to frarredondo/Pane that referenced this pull request Aug 20, 2026
* 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
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.

panes create should pin by default, with --no-pinned to opt out

1 participant