Responsive Terminal Width
- Status: Proposed
- Date: 2026-04-19
- Decider: Tan Gemicioglu
Context and Problem Statement
On physically small displays (laptops at 1080p, fullscreen Wardian window), the left content sidebar (~260px) and right AgentWatchlist (~260px) consume ~30% of horizontal space, leaving the central grid pane starved. With one or two agents in GridView, individual terminal cells fall well below comfortable reading width. The same window dimensions on a physically larger 1080p desktop display look fine — so width-media-query approaches do not help, since CSS pixel dimensions are identical and no reliable physical-inches API is available.
Two separable user pains:
- Sidebar real estate is fixed. A user on a small display has no way to reclaim sidebar pixels short of fully collapsing them, which throws away navigation entirely.
- No focus gesture for a single terminal in grid view.
maximizedAgentIdexists but hides siblings entirely; there is no intermediate "expand this one to fill width but keep others visible" option.
Proposed Decision
Two complementary mechanisms, both built on existing layout primitives.
Lever A: Resizable sidebars
Replace the static CSS variables --sidebar-content-width and --sidebar-secondary-width (defined in src/styles/App.css:41) with values written at runtime from a new Zustand store. Add a 4px hover-fattened drag handle on the inner edge of each sidebar (SidebarContentPane, AgentWatchlist).
Constraints:
- Min width: 200px (below this the sidebar contents reflow badly).
- Max width: 40% of current viewport width (prevents pathological drags).
- Double-click handle: resets that sidebar to its default (260px).
- Collapse toggles unchanged: existing
leftCollapsed/rightCollapsedstill hide the sidebar entirely; resize only applies when expanded. - Icon rail (
SidebarIconRail) stays fixed-width. It is already minimal.
This mirrors the pattern in VSCode, Slack, and similar tools. No auto-shrink-on-small-window heuristic — the user owns the tradeoff via the drag, and the persisted width handles per-machine differences naturally.
Lever B: User-forced stacked grid mode
GridView.tsx:121-131 already auto-switches to gridTemplateColumns: '1fr' when windowWidth < 1000 ("mobile" mode), rendering all agents in a single column at natural layout.row_height. Reuse this rendering path as a user-controllable mode.
Trigger: useGridResize keeps magnetic snap weights of [0.333, 0.5, 0.666] (the prior 1.0 snap is removed). On release of any horizontal drag, if any resulting track exceeds 2/3 + ε of the container width, set gridStacked: true. The ε (≈ 0.01) keeps the 0.666 snap as a valid 2/3-1/3 layout the user can rest on without being forced into stacked.
Behavior in stacked mode:
- All visible agents render in a single column at natural
layout.row_height. No special "lead" cell, no height variation. Exactly the same visual mode as the existingwindowWidth < 1000auto-stack. - Main pane is vertically scrollable.
- Exit is gesture-driven: each stacked cell renders a right-edge resize handle. Dragging it inward and releasing below the entry threshold (
< 2/3) restores the saved pre-stackedcolumn_tracks(or[0.5, 0.5]if none) and clearsgridStacked. There is no exit button — it conflicted with existing per-card toolbar affordances. - During a stack-exit drag, the grid renders a live multi-column preview so the dragged cell visibly shrinks; the preview is reverted on release if the drag did not cross the exit threshold.
maximizedAgentId(full-screen single agent) is unchanged and orthogonal.- The auto
windowWidth < 1000behavior is unchanged: stacked rendering is used if eithergridStackedorwindowWidth < 1000is true.
State and persistence
Extend the existing src/store/useLayoutStore.ts (which today persists grid column_tracks and row_height under localStorage key wardian-layout):
interface LayoutState {
leftSidebarWidth: number; // px, default 260
rightSidebarWidth: number; // px, default 260
gridStacked: boolean; // user-forced single-column mode
setLeftSidebarWidth(px: number): void;
setRightSidebarWidth(px: number): void;
setGridStacked(v: boolean): void;
resetLayout(): void;
}- Persisted via the existing Zustand
persistmiddleware tolocalStoragekeywardian-layout(no key change — Zustand persist tolerates added fields, missing ones fall back to defaults). - Per-installation, not per-workspace — laptop and desktop diverge naturally.
- Setters clamp to
[200, 0.4 * window.innerWidth]for sidebar widths. - A single
useEffectinApp.tsxwritesleftSidebarWidth/rightSidebarWidthtodocument.documentElement.styleas the existing CSS custom properties, so no consumer ofvar(--sidebar-content-width)needs to change.
File-level changes
src/store/useLayoutStore.ts(extend existing slice with sidebar widths andgridStacked).src/styles/App.css— keep CSS variables, drop hard-coded defaults if they conflict.src/views/App.tsx— wire store → CSS variables; passgridStackedtoGridView.src/layout/SidebarContentPane.tsx— add inner-edge resize handle.src/layout/watchlist/AgentWatchlist.tsx— add inner-edge resize handle.src/views/GridView.tsx— track drag-past-2/3 threshold, setgridStacked; honorgridStackedingridStylecalculation; render exit-stacked button.
Consequences
- Positive: Solves the small-display problem with one drag, no detection heuristics that fail silently.
- Positive: Stacked mode reuses the existing mobile rendering path — minimal new layout code, behaviors stay coherent.
- Positive: All state lives in one small store; CSS-variable bridge keeps existing styles working unchanged.
- Positive: Pattern is standard (VSCode, Slack, Cursor) — no Wardian-specific gestures users have to learn.
- Negative:
localStoragepersistence is per-installation, so settings do not sync across machines. Acceptable — that is in fact the desired behavior, since the laptop and desktop want different widths. - Negative: Drag-past-2/3 as a stacked-mode trigger is a discoverability bet. Mitigated by the preview affordance and the explicit exit button.
- Negative: Two ways to reach single-column rendering (auto and forced) means slightly more conditional logic in
GridView— kept to a single boolean OR.