mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
81ecd67e75
* 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>
180 lines
6.4 KiB
TypeScript
180 lines
6.4 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import type { AnnotateAgentTerminalSide } from '@plannotator/ui/utils/annotateAgentTerminal';
|
|
import {
|
|
getAgentTerminalLayout,
|
|
type AgentTerminalLayoutOptions,
|
|
} from './agentTerminalLayout';
|
|
|
|
const SIDES: AnnotateAgentTerminalSide[] = ['left', 'right', 'hidden'];
|
|
const BOOLS = [false, true];
|
|
|
|
/** Every combination the annotate shell can actually be in. */
|
|
function everyCase(): AgentTerminalLayoutOptions[] {
|
|
const cases: AgentTerminalLayoutOptions[] = [];
|
|
for (const side of SIDES) {
|
|
for (const isOpen of BOOLS) {
|
|
for (const isRunning of BOOLS) {
|
|
for (const isWideMode of BOOLS) {
|
|
for (const isBelowBreakpoint of BOOLS) {
|
|
for (const isRightPanelOpen of BOOLS) {
|
|
cases.push({
|
|
showControls: true,
|
|
isOpen,
|
|
isRunning,
|
|
isWideMode,
|
|
isBelowBreakpoint,
|
|
side,
|
|
isRightPanelOpen,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return cases;
|
|
}
|
|
|
|
const label = (o: AgentTerminalLayoutOptions) =>
|
|
`side=${o.side} open=${o.isOpen} running=${o.isRunning} wide=${o.isWideMode} ` +
|
|
`belowBreakpoint=${o.isBelowBreakpoint} rightPanelOpen=${o.isRightPanelOpen}`;
|
|
|
|
describe('getAgentTerminalLayout — invariants across the full option table', () => {
|
|
test('the panel is never docked on both edges at once', () => {
|
|
// Two dock slots would mount AnnotateAgentTerminalPanel twice, i.e. two PTYs.
|
|
for (const o of everyCase()) {
|
|
const l = getAgentTerminalLayout(o);
|
|
expect(l.showOnLeft && l.showOnRight, label(o)).toBe(false);
|
|
}
|
|
});
|
|
|
|
test('a visible panel is always a rendered panel', () => {
|
|
// Otherwise the shell reserves a dock slot for a component it never mounts.
|
|
for (const o of everyCase()) {
|
|
const l = getAgentTerminalLayout(o);
|
|
if (l.isVisible) expect(l.shouldRender, label(o)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('wide mode and sub-lg viewports never show the panel', () => {
|
|
// The panel itself is `hidden lg:flex`; a visible dock would reserve width
|
|
// for a box the browser refuses to paint.
|
|
for (const o of everyCase()) {
|
|
if (!o.isWideMode && !o.isBelowBreakpoint) continue;
|
|
expect(getAgentTerminalLayout(o).isVisible, label(o)).toBe(false);
|
|
}
|
|
});
|
|
|
|
test('a closed, idle terminal is not rendered on any side', () => {
|
|
for (const o of everyCase()) {
|
|
if (o.isOpen || o.isRunning) continue;
|
|
const l = getAgentTerminalLayout(o);
|
|
expect(l.shouldRender, label(o)).toBe(false);
|
|
expect(l.showOnLeft || l.showOnRight, label(o)).toBe(false);
|
|
}
|
|
});
|
|
|
|
test('a running terminal stays mounted while collapsed, off the layout', () => {
|
|
// Unmounting a running panel kills the PTY the user is mid-conversation
|
|
// with, so a collapsed-but-running terminal must render zero-width.
|
|
for (const o of everyCase()) {
|
|
const l = getAgentTerminalLayout(o);
|
|
if (!l.shouldRender || l.isVisible) continue;
|
|
expect(l.dockClassName, label(o)).toContain('w-0');
|
|
expect(l.dockClassName, label(o)).toContain('pointer-events-none');
|
|
}
|
|
});
|
|
|
|
test('the off-layout mount sits on the same edge it would dock against', () => {
|
|
for (const o of everyCase()) {
|
|
const l = getAgentTerminalLayout(o);
|
|
if (l.isVisible) continue;
|
|
expect(l.dockClassName, label(o)).toContain(
|
|
l.placement === 'left' ? 'left-0' : 'right-0',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('the right panel is suppressed exactly when a VISIBLE right terminal holds the slot', () => {
|
|
// The bug this guards: an off-layout (collapsed or sub-breakpoint) terminal
|
|
// must not blank the annotations/AI panel, and a visible one must, because
|
|
// the two share the right-hand slot.
|
|
for (const o of everyCase()) {
|
|
const l = getAgentTerminalLayout(o);
|
|
const rightTerminalHoldsSlot = l.isVisible && l.placement === 'right';
|
|
expect(l.isRightPanelVisible, label(o)).toBe(
|
|
o.isRightPanelOpen && !rightTerminalHoldsSlot,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('the left rail flag tracks a visible left-docked terminal only', () => {
|
|
for (const o of everyCase()) {
|
|
const l = getAgentTerminalLayout(o);
|
|
expect(l.isLeftVisible, label(o)).toBe(l.isVisible && l.placement === 'left');
|
|
}
|
|
});
|
|
|
|
test('showControls=false renders nothing, whatever else is true', () => {
|
|
for (const o of everyCase()) {
|
|
const l = getAgentTerminalLayout({ ...o, showControls: false });
|
|
expect(l.shouldRender, label(o)).toBe(false);
|
|
expect(l.isVisible, label(o)).toBe(false);
|
|
expect(l.isRightPanelVisible, label(o)).toBe(o.isRightPanelOpen);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('getAgentTerminalLayout — the hidden preference', () => {
|
|
test('hidden owns no dock slot, so it never docks on the right', () => {
|
|
for (const o of everyCase()) {
|
|
if (o.side !== 'hidden') continue;
|
|
const l = getAgentTerminalLayout(o);
|
|
expect(l.showOnRight, label(o)).toBe(false);
|
|
expect(l.placement, label(o)).toBe('left');
|
|
expect(l.isHidden, label(o)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('an explicit session open still docks the panel while hidden', () => {
|
|
// Hidden is a default, not a lock: the rail toggle and Shift Shift arrive
|
|
// here as isOpen and must still produce a real dock slot.
|
|
const l = getAgentTerminalLayout({
|
|
showControls: true,
|
|
isOpen: true,
|
|
isRunning: false,
|
|
isWideMode: false,
|
|
isBelowBreakpoint: false,
|
|
side: 'hidden',
|
|
isRightPanelOpen: false,
|
|
});
|
|
expect(l.isVisible).toBe(true);
|
|
expect(l.showOnLeft).toBe(true);
|
|
expect(l.isLeftVisible).toBe(true);
|
|
});
|
|
|
|
test('hidden lays out identically to left once open (it falls back to the historic edge)', () => {
|
|
for (const o of everyCase()) {
|
|
if (o.side !== 'hidden') continue;
|
|
const hidden = getAgentTerminalLayout(o);
|
|
const left = getAgentTerminalLayout({ ...o, side: 'left' });
|
|
expect({ ...hidden, isHidden: false }, label(o)).toEqual(left);
|
|
}
|
|
});
|
|
|
|
test('an unrecognized stored side degrades to left rather than vanishing', () => {
|
|
const l = getAgentTerminalLayout({
|
|
showControls: true,
|
|
isOpen: true,
|
|
isRunning: false,
|
|
isWideMode: false,
|
|
isBelowBreakpoint: false,
|
|
side: 'bottom' as AnnotateAgentTerminalSide,
|
|
isRightPanelOpen: true,
|
|
});
|
|
expect(l.placement).toBe('left');
|
|
expect(l.isHidden).toBe(false);
|
|
expect(l.isRightPanelVisible).toBe(true);
|
|
});
|
|
});
|