Files
Leonardo Reis 81ecd67e75 feat(annotate): configurable Agent TUI placement with durable config and Hidden state (#1050)
* Allow annotate terminal to dock on either side

* Allow annotate terminal to dock on either side

* fix(annotate): persist Agent TUI preferences through the settings registry

The Position control introduced in #1050 stored its choice in a cookie via
hand-rolled helpers that bypassed the settings registry. Every annotate
session runs on its own random port, so a cookie is scoped to one session:
the placement silently reset on the next annotate. The sibling
`plannotator-annotate-agent-terminal-default` cookie (preferred agent) had
the same gap.

Both now follow the `conventionalComments` precedent exactly:

* `agentTerminalSide` and `agentTerminalDefaultAgent` join `SETTINGS` with
  serverKey/fromServer/toServer, reusing their existing cookie keys so a
  user who already picked a side keeps it across the upgrade.
* `PlannotatorConfig` gains both as flat keys (only diffOptions, theme,
  reviewAnalysis and prompts deep-merge in saveConfig), emitted from
  `getServerConfig()` behind an `isAgentTerminalSide` guard so a
  hand-edited config.json cannot advertise a side that does not exist.
* Both keys are added to the two /api/config allowlists: the Bun annotate
  server and the hand-mirrored Pi one.

The side vocabulary moves to @plannotator/core/agent-terminal (widened to
include the `hidden` state added next) so the registry can reach it without
closing an import cycle through ConfigStore; the ui util keeps its public
API by re-exporting.

Regenerates the pinned guide viewer manifest, which shifts by 0.1 KB gz
because the settings registry now reaches into core/agent-terminal.

AI-assisted (Claude) under maintainer direction.

* feat(annotate): add a Hidden Agent TUI position and extract its layout

Builds on the Left/Right Position control from #1050.

Hidden (third state of the Position control)

  Hidden is a durable preference that the Agent TUI is not part of this
  user's layout: nothing is docked, and choosing Hidden while the terminal
  is open closes it (from either surface that offers the control). It is a
  default, not a lock. The rail toggle, the Shift Shift shortcut and a
  message routed to the agent all still open the panel for the session, and
  none of them rewrites the preference, so explicit intent wins now without
  changing what happens next session. A `hidden` preference owns no dock
  edge, so a session open falls back to the historic left placement.

  Because the Position control lives inside the terminal's own popover, and
  Hidden closes that popover along with the terminal, the same control is
  now also in the Settings dialog (General tab, annotate mode). That is the
  way back from Hidden, and it also answers the review note that Position
  could not be preconfigured before the terminal was ever opened. It is
  gated on the terminal actually being available in the session so a remote
  or runtime-less annotate never offers a dead control. Both surfaces write
  the same `agentTerminalSide` config value and read it through ConfigStore,
  so they cannot drift.

  The existing transient hide affordances (header X, resize handle click and
  drag-snap, rail toggle, Shift Shift) are unchanged and stay session
  scoped. A running agent still stays mounted off-layout when collapsed, so
  hiding the panel never kills the PTY.

Review fixes

* Extract `getAgentTerminalLayout` from App.tsx into
  packages/editor/agentTerminalLayout.ts with a table test over
  {side including hidden} x {open} x {running} x {wideMode} x
  {belowBreakpoint} x {rightPanelOpen}, asserting the invariants that can
  actually regress: never docked on both edges, never visible below `lg` or
  in wide mode, a collapsed running terminal stays mounted zero-width on its
  own edge, and the right panel is suppressed exactly when a VISIBLE
  right-docked terminal holds the slot.
* Fix `aiSurfaceOpen`, which still read `effectivePanelOpen &&
  rightSidebarTab === 'ai'` after its siblings moved to
  `isRightPanelVisible`. A right-docked terminal visually suppresses the
  panel but left the Ask AI model-discovery effect firing for an invisible
  surface, which is exactly the eager provider work that gate exists to
  avoid. The layout computation is hoisted above the consumer so it can use
  the same fact the JSX does.
* Document the right-slot invariant at both coordination sites. The
  asymmetry is deliberate: the panel evicts the terminal (which keeps
  running off-layout, so reopening resumes the same session), while the
  terminal only suppresses the panel visually so dismissing it restores the
  user's place. Symmetry would make every short terminal detour cost the
  reviewer their open surface.
* Name the `useIsMobile(1024)` literal `AGENT_TERMINAL_LG_BREAKPOINT`, tied
  to the panel's own `hidden lg:flex`.
* Restore `hideAgentTerminal()` in the resize hook instead of the raw
  setter, and point the handle at the resolved placement.

AI-assisted (Claude) under maintainer direction.

---------

Co-authored-by: Michael Ramos <backnotprop@gmail.com>
2026-08-20 17:00:22 -07:00

95 lines
3.6 KiB
TypeScript

// Via @plannotator/ui, which is how this package reaches shared code —
// @plannotator/core is not one of its direct dependencies.
import {
resolveAnnotateAgentTerminalPlacement,
type AnnotateAgentTerminalPlacement,
type AnnotateAgentTerminalSide,
} from '@plannotator/ui/utils/annotateAgentTerminal';
/**
* The Agent TUI panel is `hidden lg:flex`, so below Tailwind's `lg` breakpoint
* it has no box at all. Layout must agree with that or it reserves width for a
* panel nobody can see.
*/
export const AGENT_TERMINAL_LG_BREAKPOINT = 1024;
export type AgentTerminalLayoutOptions = {
/** Whether this session offers the Agent TUI at all (annotate mode, capability present). */
showControls: boolean;
isOpen: boolean;
isRunning: boolean;
isWideMode: boolean;
isBelowBreakpoint: boolean;
/** The user's durable preference, which may be `hidden`. */
side: AnnotateAgentTerminalSide;
isRightPanelOpen: boolean;
};
export type AgentTerminalLayout = {
/** Mount the panel (it may be mounted off-layout to keep a session alive). */
shouldRender: boolean;
/** The panel actually occupies its dock slot. */
isVisible: boolean;
isLeftVisible: boolean;
showOnLeft: boolean;
showOnRight: boolean;
/** The right-hand annotations/AI panel is not being displaced by the terminal. */
isRightPanelVisible: boolean;
dockClassName: string;
placement: AnnotateAgentTerminalPlacement;
isHidden: boolean;
};
/**
* Resolve everything the annotate shell needs to place the Agent TUI.
*
* `hidden` is a preference about the DEFAULT layout, not a lock: the rail
* toggle, the Shift Shift shortcut and Settings all still open the panel for
* the current session, and that session open arrives here as `isOpen`. So
* `hidden` owns no dock slot of its own and an explicit open falls back to the
* historic left placement. The App is what closes an open terminal when the
* preference flips to `hidden`; this function stays pure.
*
* The terminal and the annotations/AI panel share the right-hand slot. A
* VISIBLE right-docked terminal displaces the panel — but only visually, via
* `isRightPanelVisible`. The panel's own open state is left untouched so
* dismissing the terminal restores whatever the user had open. See the
* invariant comment in App.tsx's right-slot handlers.
*/
export function getAgentTerminalLayout({
showControls,
isOpen,
isRunning,
isWideMode,
isBelowBreakpoint,
side,
isRightPanelOpen,
}: AgentTerminalLayoutOptions): AgentTerminalLayout {
const placement = resolveAnnotateAgentTerminalPlacement(side);
const isHidden = side === 'hidden';
// A running agent stays mounted even while collapsed, so hiding the panel
// never kills the PTY the user is talking to.
const shouldRender = showControls && (isOpen || isRunning);
const isVisible = shouldRender && isOpen && !isWideMode && !isBelowBreakpoint;
const isLeft = placement === 'left';
const isLeftVisible = isVisible && isLeft;
const isRightVisible = isVisible && !isLeft;
const hiddenPositionClass = isLeft ? 'left-0' : 'right-0';
const wrapperClassName = isVisible
? 'flex h-full flex-shrink-0 group/agent-terminal'
: `absolute ${hiddenPositionClass} top-0 h-full w-0 overflow-hidden pointer-events-none group/agent-terminal`;
const directionClassName = isLeft ? 'flex-row' : 'flex-row-reverse';
return {
shouldRender,
isVisible,
isLeftVisible,
showOnLeft: shouldRender && isLeft,
showOnRight: shouldRender && !isLeft,
isRightPanelVisible: isRightPanelOpen && !isRightVisible,
dockClassName: `${wrapperClassName} ${directionClassName}`,
placement,
isHidden,
};
}