sugarcraft / candy-code-web
Browser UI for candy-code's WebSocket server mode, shipped as a pre-built Vite + Vue 3 bundle with a one-class PHP shim that locates it. `candy-code serve` serves it on the same port, so PHP users need no Node.
Package info
github.com/sugarcraft/candy-code-web
Language:TypeScript
pkg:composer/sugarcraft/candy-code-web
Requires
- php: ^8.3
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-05 01:02:52 UTC
README
CandyCodeWeb
The browser UI for candy-code's WebSocket server mode: a multi-session dashboard with approvals, live tool output and settings, built with Vite + Vue 3 + TypeScript + Pinia + vue-router.
It ships as a Composer package. The pre-built bundle is committed under
dist/, and a one-class PHP shim tells candy-code serve where it is, so
PHP users get the UI from composer require alone and never need Node.
Status: multi-session. Everything a turn needs — the sessions sidebar, a virtualised transcript (Markdown, code, reasoning folds), tool cards with diffs, permission cards, a composer that queues, steers or interrupts, and a status bar — plus tabs and a tiled grid of live sessions, a server-wide approvals drawer, the schema-driven settings form, and the agent, todo, background, workflow and memory panels.
Install
composer require sugarcraft/candy-code-web:@dev
The package carries no release tags yet, so the dev-stability suffix is
required: Packagist offers only dev-master, which a default
minimum-stability: stable project refuses without it.
candy-code lists this package under suggest, not require, so headless
installs stay lean.
Where the require runs: in the PHP project that hosts candy-code —
the same vendor tree candy-code serve resolves at startup. A project-local
install or composer global require both qualify; what matters is that the
two packages land in ONE autoloader. The candy-code phar channel cannot
host this UI: a packaged phar freezes the archive's own vendor as its only
autoload root and this package is a suggest, so a phar install never finds
the web package "beside it". The browser UI is a Composer-channel feature.
PHP API
use SugarCraft\CodeWeb\Assets; Assets::distPath(); // absolute, realpath()-resolved dist/ — throws RuntimeException // when dist/index.html is missing (a broken install) Assets::tryDistPath(); // soft probe: null instead of throwing, for callers that // degrade (a UI-less serve keeps the API + WebSocket up) Assets::manifest(); // decoded Vite manifest: entry => {file, css, …} Assets::version(); // bundle version, stamped into dist/build-info.json Assets::diagnose(); // structured {dist, manifest, version, error} verdict for // doctor-style surfaces, never an exception
Users need none of this: run candy-code serve and open the sign-in URL it
prints. The class exists so the server can locate the bundle and so a
distributor can verify a build's identity.
What it does
candy-code serve prints a sign-in URL; open it. The page trades the one-time
code for an HttpOnly session cookie, then holds one WebSocket speaking
candy-code.v1 (the
../candy-code/docs/SERVER.md
documents the protocol and the UI).
-
Sessions sidebar: newest first, filter, status, and a badge for every question a session is waiting on (also in the tab title, and as an opt-in desktop notification). Rows for another workspace carry a "Close
<root>/host" menu item: it asks first, retries with force when the server refuses because a turn is running, and reloads the list after. On a write-scoped connection each row also carries "Share page…" — it publishes a share snapshot and shows the full link with its expiry and a copy button — and a revoke item per live link the server lists, so any browser can revoke a share it never made; the token is kept in memory only, never in storage. -
New session in any directory (servers started with
serve --allow-dir-browse): New session opens a folder picker over the server's browse root — breadcrumb, Up, child directories with a project badge, show hidden, a path box — and starts the session in the directory chosen (a workspace host of its own when it is not the server's root). Keyboard: ↑/↓, Enter/→ to open, Backspace/← for up, Esc to cancel. Without the flag, New session starts on the server's own root, as before. -
Tabs and a grid: every session opened stays a tab (a configurable chord plus the physical digit key picks one — Alt+1…9 by default, cycled from the
tabs: Alt+1button in the tab strip; each shows its status and open questions). The grid tiles the open tabs, up to 3×3, each live — status, step, spend, the last few events, its questions and a line to prompt or steer it. The focused tile streams in full; the others are narrated by the server (a tail every 2 s) and catch up at once when focused. The layout is remembered per browser. -
Approvals drawer (the ⚠ count in the top bar): every question open in any session of the server, oldest first, grouped by session and answerable in place. A question arrives the moment it is put, whether or not this page follows its session; opt-in desktop notifications also say when a turn finishes out of sight.
-
Device push (opt-in): a "Notify me on this device" toggle in Settings subscribes this browser through a service worker served at
/sw.js, so a question or a finished turn still raises a notification when the page is closed. Default payloads are generic catalogue text plus the session id: the protocol lets any other client subscribe withname: trueto receive the session title in payloads (candy-code SERVER.md), but this UI never requests it, and a transcript line never rides a payload. The toggle needs a secure context (HTTPS or localhost); switching it off unsubscribes both the browser and the server. -
Client-hosted tools (when the server opens
server.clientTools): after following a session this tab announces its local tools — the samplebrowser_context(language, time zone, viewport, pixel ratio, colour scheme, online state; nothing private) — runs the model'sclient__…call when the session'stool.client.callcarries this connection's registration, and answers within the call's deadline. -
Transcript: virtualised (
@tanstack/vue-virtual); replies stream in as Markdown (markdown-it, raw HTML off, then DOMPurify — model and tool text are untrusted), fenced code coloured by a small highlighter loaded on first use; reasoning folds; a tool card per call with its arguments, output (the full text on request when the event was capped) and diff. Reaching history above the subscribe window: scrolling to the top edge pulls the older page (session.getbeforeIndexpaging; a load more button does the same on click), the viewport keeps its place as rows prepend, and a failed pull says so inline — in the server's own words, not translated — instead of retrying silently. Diffs render each row through one highlight pass and paint at most 500 of them until show more, so a whole-workspace diff stays operable. Two toolbar switches are remembered per browser: Show thinking opens every settled fold, Expand tool cards opens every card until a card is toggled by hand; a failure or refusal always opens. -
Permission cards: once / always (this session) / always (save) / reject / reject & stop,
yanon a focused card. Always (save) is the only answer that persists a durable config grant, so it is two-step: the first click spells out what it saves, the second commits. A card whose answer never settles re-arms its buttons after ten seconds — a dead card is never the retry story. The first answer from any client wins — which means every signed-in browser of one serve is the SAME operator (see Trust model). -
Composer: Enter sends; while a turn runs, steer, queue or interrupt — offered first is the server's
queueMode(server.hello'sdefaults.delivery;steerunless set, as the TUI's Enter does mid-turn); Stop or Esc Esc cancels;/completes server-runnable commands,@completes mentions — files and@mcp:resource mentions of connected servers — and a leading#is the TUI's todo quick-add. Pasting or dragging images files them as chips that ride the send, and PDFs/documents ride the same surface within the server's per-message document budget. Ctrl+↑ walks the prompt history and Ctrl+G searches it (TUI semantics: Enter fills the draft, Esc restores yours); a paste over the TUI's collapse thresholds lands as a[Pasted #n +X lines]chip that expands at submit, so the wire carries the whole text while the draft stays short. The mic button on it is dictation — see Voice input. -
Status bar: activity and step — with a live elapsed turn timer while a turn runs (
working · 0:42 · step 3/20; hidden on a mid-turn reload, where no start was ever observed) — context used, spend, model, and the permission mode (changeable); plus the branch chip, whose PR dot and CI status come from the server's workspace facts. -
Reconnects after 0.5 s doubling to 15 s (±30 % jitter) with a fresh ticket, resuming every followed session from its gap-free
seqcursor. Six tries make up the ladder; when it runs out the tab stops retrying and says the server is gone — the same verdictcandy-code attachreaches after its exhausted ladder (SERVER.md) — with Try again to spend a fresh one. -
Side panels (the panels button — twelve, per the
PANELSregistry insrc-web/stores/agents/panels.ts): Agents — the session's delegated runs as a live tree, also under eachTaskcard, each opening into an agent view with the run's own transcript, a box to message it (or continue it once finished) and pause / resume / cancel, plus Background for a run the conversation itself delegated (the TUI'sCtrl+X b: the run moves to a background session); Todo — the agent's todo list, live; Background —/bgsessions with output, stop and send to this session, plus the launch's background shells as a Processes section — rows with state, age and recent output (shells.list,shells.stop), where an interactive (pty) shell's row expands into a typing pane: a cursor-threaded polled tail (shells.output, while the row stays expanded) rendered from SGR classes, plus a line and named keys that leave as single writes (shells.input). This is a POLLING, line-oriented door — not PTY-over-WebSocket, and the browser is not a terminal emulator; without write scope the pane is watch-only, exactly like Stop; Workflows — run one, and the session's/workflowandWorkflowtool runs; Memory — notes per scope, search, add, edit, delete, and a history tab listing the home store's commits with a confirmed undo (write scope); Rules — the permission policy in the open (mode and its source, allow/ask/deny rules, grants picked up as the session ran; read-only — the dials live in the status bar and settings); Catalog — every agent this server can delegate a run as, with model, provider and mode; Changes — the workspace's uncommitted files with per-file diffs, each row also opening the read-only viewer; Files — workspace file search with the same read-only viewer (fs.search,files.read— plain text with line numbers, never markup; binary files answer with a size notice, long ones load more by offset); Context — what the next request is built from, as structure rather than/context's text, plus the directory-trust prompt; Generate — the media-generation form and live job surface (shown only when the server says it can generate); Usage — the session's spend ledger (the/usagetwin), where every dollar figure carries the ≥ law: an unknown rate is a lower bound, never a bare "$". -
Command palette (Ctrl+K / ⌘K): sessions, slash commands, panels, new session, theme.
-
Voice input: the composer's mic dictates through one of two engines, and the button's title says which is live. The browser engine hands audio to the browser vendor's speech service (Chrome routes through Google, Safari through Apple) and contacts nothing on the candy-code side; the server engine — chosen whenever the server advertises
transcribe— records locally and sends the audio to the server, from where it goes to your own configured voice endpoint. Firefox has no browser engine at all, and a plain-http serve on a LAN host is not a secure context, so there the button rides the server engine or hides: the browser refuses both APIs in an insecure page. -
Canvas artifacts: a tool can answer with self-contained HTML, rendered as a CanvasCard in a sandboxed frame served from
POST /api/canvas(the containment story is documented in candy-code SERVER.md); generated media shows through the matching cards. -
Keyboard:
?opens the help overlay listing the live chords, and the keymap is rebindable and persisted per browser (src-web/lib/keymap.ts). Chords quoted elsewhere in this README are defaults, not law. -
Installable, localized: a PWA manifest plus a service worker (
public/manifest.webmanifest,/sw.js— the same worker carries push), and the UI's strings arrive from the server at runtime (server.strings,src-web/stores/locale.ts), so the bundle follows the server's language configuration; the top bar reflows for phone-width viewports (pinned by the e2e suite). -
Settings (the settings link in the top bar): a form generated from
settings.schema— your tier or a trusted project's local or committed shared file, each value's provenance, env and flag locks, apply-mode badges — with a diff preview of the file before anything is saved (settings.preview, thensettings.set). -
Security notes: every untrusted string — model and tool text, server paths and reasons — renders as data, never markup (raw HTML off in
markdown-it, then DOMPurify with the html profile only; server-supplied panels interpolate with{{ }}). Three disclosures a reader should know. One: sign-in keeps the session secret inlocalStorage(keycandy-code-web:login-secret, origin-scoped, shared across tabs) so reloads and new tabs re-mint tickets without another sign-in; anything that can execute script on this origin — or a shared browser profile — can read it for as long as it sits there: the exposure is at-rest persistence while signed in. Signing out clears it (logout()deletes the key, and any 401 from the server clears it the same way), so until then keep the host page's script surface clean and treat browser storage as holding a credential. Two: the composer keeps sent prompts in a local ring (candy-code-web:promptHistory, capped at 50, this origin only, never sent anywhere beyond the server that answered those very prompts) — sign-out clears that key in every open tab of this browser (each tab's in-memory copy drops with it, so nothing resurrects on the next send), and the command palette's “Clear prompt history” action (theActionsgroup, after a confirm) clears it any time without signing out —clearPromptHistory()insrc-web/stores/promptHistory.ts. Three: UNSent composer drafts persist too (candy-code-web:drafts) — this is user-typed text, which routinely holds file paths, pasted credentials and proprietary code. Writes are debounced, each entry is clamped to 16 KiB (longer text lives in memory for the session, not storage), at most 50 sessions are kept, and a page-hide flushes before unload; sign-out clears the key in every open tab, a session leaving the list drops its own row, and a quota refusal sets a visible "storage degraded" state instead of diverging silently. Signing out is the whole-browser clear; the 50-session window bounds what an endless sign-in could accumulate.
Browser storage
Everything this UI keeps client-side lives in localStorage under the
candy-code-web: prefix. Default retention is indefinite — state survives
server restarts; only the first three keys are credential- or content-
sensitive enough to be cleared on sign-out.
| Key | Contents | Bound | Cleared by |
|---|---|---|---|
login-secret |
sign-in secret (a credential) | one | sign-out; any server 401 |
promptHistory |
recent SENT prompts, newest first | 50 | sign-out (all tabs); palette "Clear prompt history" |
drafts |
unsent composer text per session | 50 sessions × 16 KiB | sign-out (all tabs); session leaving the index; FIFO eviction |
tabs / mode / focused / lastSession |
open tab ids / list vs grid / focused session / last opened id | tabs: 12 | sign-out keeps them (device convenience); a vanished session prunes its own rows |
theme / notify / diffStyle |
UI preferences | enum values | kept |
push |
device-notification consent | boolean | its own Settings toggle |
panels.open / panels.active |
side-panel drawer state | enum values | kept |
transcriptView.showThinking / transcriptView.expandTools |
transcript toolbar toggles | booleans | kept |
keymap / tab-chord |
rebinding overrides per browser (tab-chord is the legacy chord slot, migrated forward on read) |
per-chord map | kept |
The table is machine-checked: src-web/__tests__/storageInventory.spec.ts
derives every persisted key name from the sources and fails if a key ships
without a row here (or a row outlives its key).
Trust model
This is a single-operator surface by design. Whoever authenticates to
candy-code serve gets everything that server can do — the candy-code
server's own words: "Anything that can send a prompt can run code as you"
(SERVER.md). The mechanism this UI exposes is "the first answer from any
client wins": every signed-in browser tab of one serve is the SAME operator
— any of them answers a permission card, any of them can revoke or publish
a share, even one that never created it. Treat extra browsers, coworkers'
tabs and old laptop sessions as the same pair of hands. The server binds
127.0.0.1 by default and refuses a non-loopback host without
--allow-remote; deciding to expose it is governed by SERVER.md's "Remote
access" and "Behind a reverse proxy (TLS)" chapters (--tls-cert/--tls-key
terminate TLS in the server directly, --allowed-ips bounds who may
connect). The share page is the ONLY read-only second-audience door: a
tokenised snapshot link with an expiry, never a driver's seat.
Deployment hardening
Decide exposure before headers: the UI is as public as the server behind
it (loopback default, --allow-remote, TLS and IP bounds — see Trust
model and candy-code SERVER.md; the Dockerfile's shipped
serve --host 0.0.0.0 --allow-remote posture makes that decision for you).
To run a custom build of this bundle, start the server with --web-root (or
CANDY_CODE_SERVER_WEB_ROOT, candy-code ENVIRONMENT.md) — the only
documented way to serve your own dist/. And the server ships dist/assets
uncompressed (~650 KB cold, no .gz/.br siblings): compression is the
front proxy's job, so turn gzip/brotli on for /assets/ — the file names
are content-hashed and served immutable, so proxy-compressed copies stay
valid across deploys.
candy-code serve already ships a Content-Security-Policy (alongside
nosniff, Referrer-Policy: no-referrer, frame denial, and same-origin
COOP/CORP): Responses::SECURITY_HEADERS is applied by secured() to
every response — the static UI bundle and share pages included — except
the WebSocket 101 handshake, whose headers belong to RFC 6455. The shipped
policy is
default-src 'self'; connect-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'
The carve-outs are deliberate: connect-src 'self' covers both the
same-origin /api calls and the /ws socket (CSP3 counts a same-origin
ws: as self, so the socket needs no wider admission); data: and
blob: feed pasted images and media.fetch bytes rendered through
same-origin object URLs; 'unsafe-inline' in style-src is what inline
theming costs. The sanitising above stays the primary injection defence —
CSP is the net under it.
A deployment wanting tighter can have its front proxy send a stricter
policy on top: browsers intersect multiple Content-Security-Policy
headers, so additions only ever narrow the default. Typical wins are
pinning connect-src to the exact https://host origin, or moving the
bundled styles to fetched files and dropping 'unsafe-inline'. Do not
widen any directive from the shipped default — in particular ws:/wss:
generals are unnecessary (the socket already passes) and would admit
cross-origin sockets the app never uses.
Developing the UI
The TypeScript lives in src-web/ so it stays out of the PSR-4 src/ tree.
Node is pinned in .nvmrc.
npm ci npm run dev # Vite dev server; proxies /ws and /api to 127.0.0.1:7420 npm run typecheck # vue-tsc npm run lint # eslint npm test # vitest (happy-dom): protocol client, reducer, stores, components npm run build # writes dist/ npm run gen:protocol # regenerate src-web/protocol/generated.ts from the wire schema npm run gen:strings # regenerate src-web/lib/strings.en.generated.ts from the mirror JSON npm run e2e # Playwright (Chromium) against a real `candy-code serve`
For npm run dev, start the server with
candy-code serve --allowed-origin http://localhost:5173, so it accepts the
dev page's origin.
The src-web/ tree, one line per directory (a file-by-file map here rotted
once and would again — the tree IS the inventory):
src-web/
protocol/ generated schema mirror, the JSON-RPC-over-WS client (hello,
resume, watchdog, reconnect ladder), the gap-free seq cursor,
and sign-in/ticket auth (incl. logout's storage sweeping)
stores/ Pinia state: connection, the session index, per-session
transcript state (the reducer), approvals, layout (tabs,
drafts, grid mode), settings (+ the schema-driven form),
push, prompt history, locale, transcript toolbar view,
keymap-facing stores, agents/ (delegation tree, todos,
background, workflows, memory, the twelve-panel drawer)
components/ transcript and its cards (item, markdown, reasoning fold,
tool card, diff view, file viewer), composer and queue strip,
permission cards and the approvals drawer, session sidebar,
status bar, connection banner, grid/ (tabs, tiles), panels/
(the twelve side panels and the command palette), dialogs
(history search, help overlay, about modal, canvas and media
cards, directory picker)
lib/ markdown + sanitising pipeline, lazy highlighter, diff model,
format (incl. the spend ≥ law), dictation + recorder, paste,
media, questions, tab chord, i18n runtime, line diff, ANSI,
serialization, slow-pending verbs, storage helpers
views/ DashboardView, SessionView, LoginView
dist/ is generated: never hand-edit it. THE LAW: a change to the web
source rebuilds dist/ with npm run build and commits the result in the
SAME commit as the source — never in a follow-up. Enforcement: the web
workflow (.github/workflows/web.yml, step "Committed dist/ matches the
source") rebuilds from a clean npm ci and fails when the committed dist/
differs from that build; npm run check:dist runs the identical verdict
locally before you push (it judges the committed tree, so commit your source
first). The build is deterministic (content-hashed file names, no
timestamps), so a clean rebuild of unchanged source is byte-identical.
src-web/protocol/generated.ts is generated too, by
scripts/gen-protocol.mjs from
../candy-code/docs/protocol/candy-code.v1.schema.json (itself generated from the PHP
protocol classes). web.yml regenerates it and fails on a diff, so a server
protocol change the client was not updated for is caught in CI.
Adding a user-facing string
Every UI sentence is an i18n string, and this package does not own its
English copy: src-web/lib/strings.en.generated.ts is a committed MIRROR.
The chain is one direction:
- add the key in the sibling's catalogue —
candy-code/lang/en.phpand its eleven locale twins (all-or-none); - the sibling regenerates
candy-code/docs/protocol/web-strings.en.json(gen-web-strings.php); - here,
npm run gen:stringsrewrites the mirror from that JSON, and the mirror is committed with the change. The protocol mirror (below) has a CI drift leg; this one does not yet, so the regeneration is part of the commit ritual, not something CI will forgive you for skipping.
Then call sites use t('web.<area>.<name>'). Two laws bite: keys must carry
the web. prefix (setWebStrings() in src-web/lib/i18n.ts refuses any
other prefix from the server too), and minting a key only in this package is
impossible — i18nCoverage.spec.ts here reddens on an unknown key and the
sibling's WebStringsDriftTest reddens on a one-sided catalogue. At runtime
the server sends its own strings (server.strings), so the committed mirror
is the offline fallback — which is why it must never be hand-edited.
Citation grammar
Comments in src-web/ carry internal work-record handles — WEB-1a,
C-WEB-4.a, C-SEC-5.b, ISS-052, Appendix O, bare lane names. They
refer to the monorepo's development archive (plans and reports under the
repository's docs/), which does not ship inside this package and is not
linked from here. They are archaeology tags for humans with repo access,
not documentation references: the sentence around one is the comment; read
it and ignore the handle. Shipped USER-facing prose never carries them.
End-to-end tests
e2e/fixtures/server.ts starts php ../candy-code/bin/candy-code serve --port 0 over a throwaway git repository and HOME, on the offline echo
provider (no network, no keys; it needs ext-pcntl, ext-posix, ext-ffi).
A prompt of ::tool <Name> <json-object> lines makes the echo provider call
those tools for real, which is how the suite drives permission questions,
tool cards and diffs. Scenarios: the sign-in link and the token, a spent
code, send and see the echo, markup injection refused, a tool call with its
question and diff, a rejected call, a queued prompt, Stop, /clear through
command.exec, two tabs on one session (first answer wins), a tab opened
mid-turn, a dropped socket resuming from its cursor, and a server restart.
PHP_BINARY and CANDY_CODE_BIN override where PHP and the server are.
Accessor naming (2026-10-24)
The monorepo's bare-accessor convention (no get*() wrappers for state that
is already readable) applies to this package's own surface too — and a census
of src-web/ found it already holds: Pinia setup stores expose plain state and
computed refs, classes use ES get accessors, and defineExpose surfaces are
verbs (focus, jumpToEnd). The getX() names that remain here are external
contracts, not ours to rename: DOM/Web API methods (getAsFile, getItem,
getRandomValues, getAttribute), TanStack Virtual options (getScrollElement,
getItemKey, getVirtualItems, getTotalSize), and Pinia internals
(getActivePinia). TypeScript has no PHP-style name collision (a getX() can
coexist with x()), so this side never needed deprecate-and-alias forwarders;
it needs the opposite discipline — do not mint getFoo() twins for state a
computed or property already reads, and keep any future rename UI-local: wire
field names mirror docs/protocol/ and are server-owned truth.
Tests
composer install && vendor/bin/phpunit
tests/AssetsTest.php checks the committed bundle itself: the manifest's
files exist and are content-hashed, index.html loads the entry by a
relative URL, the version matches package.json, and no source map or
.env file leaked into dist/.
License
MIT