# Web App

## Workspace HTML previews open on a separate origin

"Open in new tab" for a Workspace html file used to serve the page from the app's own origin under a CSP sandbox without `allow-same-origin`. That kept Agent-generated markup away from the session cookie and the API, but it also put the document in an opaque origin, so `localStorage` and `document.cookie` threw `SecurityError` and any third-party embed on the page — analytics, chat widgets, maps — failed outright. The query-parameter URL had a second problem: a page's relative subresources resolved to `/api/sessions/<id>/files/<name>`, a route that does not exist, so multi-file generated apps could never load their own CSS or JS.

Previews now open on a **separate origin** with path-based URLs. The link points at `GET /files/preview-redirect?path=`, which authenticates the caller, mints a short-lived HMAC token bound to the Session, the preview host and an expiry, then 302s to `/preview/<token>/<path>`. Locally the app is canonicalized onto one loopback name (`localhost`) and previews are served from the other (`127.0.0.1`) — cookies are keyed by host and ignore port, so those are genuinely separate cookie jars, which a second port would not have given. Real deployments set `PENGUIN_PREVIEW_ORIGIN`; with neither, the redirect falls back to the old same-origin sandbox and `previewIsolated` on `GET /api/me` reports `false`, so the Files panel marks the link rather than letting the page break silently.

Because the origin is now the isolation boundary, the preview response no longer strips `allow-same-origin`: the page gets a normal origin, storage and cookies work, and embeds run. Several details keep that safe. The app and the preview origin are the same process on two hostnames, so the preview host is locked to serving `/preview/*` only: its `/api` answers `401` and every app route `302`s to the canonical host, so a session cookie is never set or honored there — otherwise Agent HTML on the preview origin could call `/api` same-origin and act as the user. The token binds the preview host and `/preview/...` refuses to serve on the app origin, without which this would be a same-origin XSS with full API access. Responses carry `Referrer-Policy: no-referrer`, since the token-bearing URL would otherwise leak through `Referer` to exactly the third parties this change enables. And the path is still re-resolved against the Workspace server-side, so `..` and symlink escapes are rejected as before; a bad token, an expired one, the wrong host and an out-of-bounds path all answer a bare 404.

The preview URL takes its port from the server's own binding rather than the browser's current origin. Those differ in development — the SPA runs on Vite's port and only `/api` is proxied — so deriving it from the request would point at a port that does not serve `/preview` at all, and the tab would fail to connect. In production the two coincide. Only a loopback-name bind (`127.0.0.1` / `localhost`) offers a loopback preview; a wildcard bind (`0.0.0.0` / `::`) or a specific non-loopback address has no reachable counterpart — `localhost` may resolve to `::1`, which a `0.0.0.0` bind never serves — so those fall back and must set `PENGUIN_PREVIEW_ORIGIN`, rather than pointing somewhere dead while `/api/me` claims isolation.

The link is a plain anchor with `rel="noopener noreferrer"` rather than a fetch followed by `window.open`: opening a tab after an await trips popup blockers, and a script-opened window keeps an `opener` handle back to the app — the exact reference the separate origin exists to deny. The server also binds the IPv6 loopback alongside IPv4, because `localhost` commonly resolves to `::1` first and every preview URL would otherwise refuse the connection.

The in-app rendered view now goes through the same door. It used to feed the file's text into its iframe via `srcdoc` — a document with no real base URL, so a page's relative subresources (`foo.png`, `app.js`, `style.css`) could never resolve — while "Open in new tab" already served real URLs on the preview origin. With isolation available, the Files panel's iframe now loads the same `preview-redirect` URL, so relative assets resolve and the page's storage works in-panel too, under a sandbox (`allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads` — now named exactly in the docs) that stays strictly tighter than the unsandboxed new tab; without isolation, the old `srcdoc` fallback and its warning remain. The source text is fetched lazily on the first Source toggle, so big files are no longer downloaded twice and a transient fetch failure affects only the Source view.

Follow-up hardening on the isolation signal: `previewIsolated` is refetched after a UI login rather than trusted from mount time, so a deployment without a separate origin no longer sends the panel down the isolated path after logging in through the UI; and the server rejects a configured preview origin whose host equals the app's own — cookies ignore ports, so a port difference separates nothing — reporting `false` on `/api/me` and engaging the safe fallback instead of granting `allow-same-origin` to a same-origin document.

## Unified form controls, one notification rule, and deduplicated copy

The form-control layer had grown five near-identical copies of the same scaffolding. A shared `Field` wrapper now owns the label / hint / error markup, and a shared `controlBase` class string owns the border, hover, focus-ring and dark-mode treatment, so `Input`, `Textarea`, `Select`, `OptionMenu` and `PasswordInput` all read from one source instead of each carrying their own. `Select` dropped its hand-rolled open/close/position effect and now reuses the same `usePortalPanel` hook `OptionMenu` already used, so both menus flip up-or-down, clamp to the viewport and close on scroll/Esc through one code path. The close button triplicated across `Modal` / `Drawer` / `Sheet` became a single `CloseButton`, and the chevron, checkmark and plus glyphs that were inlined at a dozen call sites became shared icon components. The Agent runtime tab's thinking-level and compaction-mode dropdowns dropped their separate "Reset to default" link (a `ResettableOptionMenu` wrapper) in favour of a plain `OptionMenu` — the link only rewound an in-session pick, which closing the dialog already does.

Notifications now follow one rule. A normal result — a save, an "already up to date", a connection failure — surfaces as a toast that slides in from the top and disappears on its own; the toaster gained an `info` tone alongside success and error for neutral messages. A value error on a specific field no longer lands in a generic red line at the bottom of the form: the offending input turns red and its message sits directly beneath it, so on a failed submit the eye goes straight to the field to fix. Every create/settings form was moved onto this split — required-field, format and mismatch errors went inline, ad-hoc emerald "saved" banners and transient action failures became toasts — with page-load-failure states that pair with a Retry button left inline where a disappearing toast would be wrong.

Review hardening on that rule: the inline error text is now announced to assistive tech — `FieldError` carries `role="alert"` and every shared control links its message via `aria-describedby`, wired once in the control layer so all forms inherit it. `Select` and `OptionMenu` gained the same `error` prop the text inputs have (red border + message below), so a failed dropdown participates too. And a server rejection now lands under the field its **error code** names — the change-password and create-user dialogs used to pin every rejection to one hardcoded field, so a too-short new password rendered under "Current password" / "Username".

The i18n catalog was pruned and deduplicated: 41 dead keys removed, and roughly two dozen values that were identical in both languages (Name, Username, Role, Actions, Created, Cost, Time, …) folded into shared `common` keys so a label is defined once. Mid-sentence "Agent" in English copy was lowercased to "agent" — it is a common noun, capitalized only at the start of a label or a sentence — while the domain terms (Workspace, Token, Task, Session, Project, Trace) and proper names (Agent State, AgentHub) keep their capitals.

## Confirm before a skill update overwrites installs

Updating a skill install — the card's rotate button, or a per-agent "Update" in the manage-installs dialog — reinstalls the library copy over the agent's files. That overwrite silently dropped any local edits to the installed skill, with no confirmation and (unlike an Agent State import) no snapshot. It now opens a confirmation dialog first: it lists each affected agent with its `v_old → v_new`, warns that the overwrite loses local edits, and suggests exporting a backup if needed. A shared `ConfirmModal` (Cancel / Confirm footer, danger or primary tone) backs it and is reusable for future confirmations. Existing destructive actions already confirmed (deleting a user / agent / project / session / vault entry / schedule), and an Agent State import still auto-snapshots and additionally confirms on a version conflict — those are unchanged.

## Localized error prompts, full error detail, and required-field markers

The server's `{error:{code,message}}` message is English-only, so a Chinese-locale user saw untranslated text for anything the frontend didn't map — and only two codes were mapped. `apiErrorText` now localizes ~two dozen common codes (auth, permission, not-found, id-already-taken, file/path, task-in-progress, version-conflict, …) via a new `S.errors.byCode` table, falling back to the raw server message only for unmapped codes, and every error surface that previously showed the raw `e.message` now routes through it. (The stale comment claiming the server messages were Chinese is fixed — they're English.)

The Cost Center's recent-errors table truncated each message to one line with the full text reachable only through a hover title, so a request failure showed just its head — e.g. `llm request error: 402 openai_error` — and cut the provider's actual reason. Each message still shows on **one line by default** (an error storm shouldn't fill the table), but **clicking it expands the full text in place** (click again to collapse); the full text stays in the hover title too. The server keeps up to 500 chars; the full stack stays in the logs.

Required form fields now carry a red `*` after the label, so required vs. optional is visible at a glance. The mark is driven by a `required` prop on the shared field controls (`Input`/`Textarea`/`Select`/`OptionMenu`/`PasswordInput`), which also sets `aria-required` while deliberately not forwarding the native `required` attribute (the app validates on submit; browser bubbles would collide with the inline field errors).

## Sidebar: the session list loads only active sessions; folders load on click and page on their own

Subagent- and schedule-created Sessions already live in their own labelled, collapsed folders (Subagents / Scheduled), so the small source badge repeated next to each title was noise — it's gone.

The list's fetching and its "More" affordance were also untangled. Server pagination used to be source-blind: a page of 20 could arrive full of subagent/scheduled/archived rows that all disappear into collapsed folders, so the visible active list stayed short while "More" kept showing (or — worse — active sessions parked behind such pages were unreachable, because a group whose first page held no active row never offered "More" at all). The session list endpoint now takes a `category` filter (`active` / `subagent` / `schedule` / `archived`, the same archived-wins precedence the sidebar renders by) plus an optional `counts=1` for per-category totals, and the sidebar keys everything off it:

- The default load fetches **only the active** (user-created, non-archived) rows — 10 per page, "More" loading 10 at a time — plus the category totals; archived, scheduled and subagent Sessions are no longer loaded up front.
- A group's "More" counts **only the outer active rows** (the collapsed folders never feed it), and since the pages it fetches are category-filtered, every click now grows the visible list by a full page.
- A collapsed folder is labelled from the server totals and fetches its first page **when first opened**. Once open it pages independently with its own "More" row — each group and each folder advances alone, without dragging pages in for the others.
- The counted response also breaks the totals down **by Workspace path**, and every group — workspace groups included — shows a folder only for its **own share**: exact label counts, and "More" only while that share isn't fully loaded. (Initially a workspace group inferred its folders from per-Agent totals, so an Agent's subagent/scheduled Sessions living in *other* Workspaces made unrelated groups grow an empty folder whose "More" click made the whole folder vanish.)
- Review follow-ups: the paging cursor is each pair's **fetched-row count**, so a deep-linked row that `add()` slips into the list no longer makes the next page skip a server row; a clicked "More" disables and reads "loading…" while its fetch runs (in workspace mode a page can land entirely in other groups — the click still visibly does something); and the server locates each Session's earliest Trace shard in the **same single directory walk** discovery already does, so `counts=1` classification no longer re-walks the Traces tree once per Session.

One consequence of not loading folder content up front: in workspace grouping, a Workspace whose loaded Sessions are all archived or automation-created no longer forms a group by itself (nothing visible remains to hang it on); its Sessions stay reachable through agent grouping's folders.

The auto-opened "last conversation" follows one rule now too. Entering `/chat` without a Session used to take the flat list's literal first row (the first Agent's newest loaded row — not the Project's newest, and after a deep link possibly an archived or subagent row), and the collapsed rail's entry merely skipped archived rows. Both — plus the after-delete jump to a neighbouring Session — now pick the newest loaded **active or schedule** conversation: an archived row is hidden by choice and a subagent Session belongs to its parent conversation, so neither is ever auto-opened. When the auto-opened conversation is schedule-created, only its group's Scheduled folder expands and loads; the outer active list's "More" is untouched.

## Every file-writing action confirms first; an empty save says so

Actions that write files on the server used to be a mix: deletions confirmed, a skill *update* confirmed, but a settings save (AGENTS.md / system prompt, runtime params, tool overrides), a skill *uninstall* (which deletes the installed copy, local edits included) and a workspace upload landing on an existing name all executed on one click. They now all confirm first through the shared `ConfirmModal`, which also got a face-lift: a compact card with **no title bar** — just a tinted icon badge that sets the tone at a glance (red warning triangle for deletions, neutral pencil for saves/overwrites), the message beside it (each message carries its own subject and consequence), and a small Cancel/Confirm pair. The former titles still name the dialogs for assistive tech. Every existing confirmation (delete session/agent/project/schedule/vault entry, model dialog writes, import version conflict) was moved onto the same component, so confirmations look identical everywhere. Adding a vault variable under an already-configured name also confirms now (it overwrites an unrecoverable value); quick-set controls inside the chat (approval mode, thinking level) intentionally stay one-click.

Clicking save with nothing changed used to do nothing silently (most forms skipped the request; the models dialog even reported "Saved"). Every save path now detects the no-op — the agent settings tabs, the schedule edit dialog and the model edit dialog — and shows an info toast: "No changes to save".

## The chat input steers or queues while a task runs

Sending during a run used to go nowhere — the input stayed usable, but submitting was a no-op until the task finished. While a task runs, the composer now does two real things: **Steer** (the default) delivers the draft mid-run as a `[user_steering]` message riding into the model's next request, and **Queue** posts the whole draft — images, skills and the @ handoff included, through the full normal composition path — with `queueIfBusy`, so the server holds it and auto-sends it as an ordinary follow-up task when the run ends. The mode is picked in a new More settings popover on the input toolbar — an extensible panel of setting rows, available in draft state too, replacing an inline segmented switch that overflowed on narrow screens — and the choice is remembered per user the way the sidebar grouping mode is, so a running-state send simply follows it. Stop and Send also became one button: with the composer empty it stops the run, and any content turns it into Send, which steers or queues per the remembered mode. The toolbar itself is now a left/right row whose pickers render through the shared portal layer, so a panel is never clipped by the scrolling toolbar on a narrow screen. A steer that races the run's completion falls back to that same full send path. Queued follow-ups survive abort and compaction, with a server-driven "N queued" hint near the input that survives reloads; a successful steer shows a lightweight queued hint until its message appears in the stream. Steering messages render as in-flow, user-styled chips in the conversation — they never split the Task — and the running placeholder explains the delivery.

## /model switches the conversation to another model

A `/model` slash command in an active idle session opens the model picker (the same list and grouping as the draft picker, current model marked). Picking another model continues the conversation handoff-style: a NEW session for the same agent on the picked model, reusing the source session's Workspace and approval mode, whose first message opens with a paired `[model_switch_from]` source block naming the source session, its latest trace file, the workspace and the previous model — the model reads the earlier context from the source trace when it needs it. Any text left after the command rides into that first task like a normal send, and the new session shows a "switched model" banner linking back to the source. The command consumes its token immediately — Escape and click-outside just close, like `/compact` — the run state is re-checked at pick time, and picking the current model is a no-op.

## Thinking level becomes a per-turn override in active sessions

The read-only thinking tag in an active session becomes an editable picker listing just the levels. It initializes to the Agent config's level, fetched through the existing agent-config endpoint, and while untouched it sends nothing with a task — the config fallback keeps applying, so a mid-session config edit still takes effect. Once the user picks a level it pins for the session and rides on every task, without ever writing through to the Agent settings; the draft picker's switch-becomes-default behavior is unchanged.

## Call descriptions on tool cards, and approvals that show the payload

Tool-call cards show the model-written call description as the collapsed-header subtitle, and the agent settings Tools tab gains a per-row call-description switch for each tool entry whose parameters declare the property. The pending-approval block renders the decoded file-tool payload — `old_string` / `new_string` / `content` alongside the path — in a scrollable pane, and the approval preview keeps the real command for the shell tool: the user approves the actual change, not the model's summary of it. File-tool previews shorten the path to one parent directory plus filename; the full path stays in the expanded arguments.

## Free models carry a Free badge

Free rows — the `:free` variants and the Free Models Router — now show a light-yellow "Free" badge, a new shared Badge tone deliberately distinct from the warning amber, on the Models page cards, next to the default and vision badges, and in the chat model picker. A shared `isFreeModel` helper decides: the entry must carry explicit pricing with all three buckets zero, so an unpriced or partially priced row — its costs merely unknown — never badges as free.
