This page captures the original UI requirements and the intended end state. It is a functional spec and a design contract.
- UI ships as static assets embedded into the
foxxycodebinary (build taghttp). - Runtime has no auth and no API key checks for the UI.
- UI must work over the same origin as
foxxycode http. - UI copy is English.
- Favicon matches foxxycode.dev (
/foxxycode-favicon.svg, same mark asdocs/assets/foxxycode-logo-mark-flat.svg, plus PNG/ICO fallbacks embedded with the SPA). - Browser baseline: Chromium 104 (JCEF in PhpStorm/IntelliJ 2022.3.3 — see
docs/intellij-embedding.md). Shipped CSS/JS must not use features newer than Chromium 104: no:has(),oklch()/oklab(),@container, or native CSS nesting;dvh/svhonly with a precedingvhfallback for the same property; noArray.prototype.toSorted,Promise.withResolvers,URL.canParseand similar post-104 JS APIs. color-mix()is allowed instyles.csssources only in the build-resolvable form (in srgb, arguments statically resolvable per theme — hex/rgb()/transparent/var()chains).external/ui/postcss-resolve-color-mix.mjscompiles every occurrence to Chromium-104-safe literals or per-theme--cmix-*variables; the build fails on unresolvable expressions.npm --prefix external/ui run check:compat(part ofbuild:go) scans the built bundle and fails on baseline regressions.
The Settings screen leads with two synthetic client-side tabs before the schema-derived config tabs:
- General — the single app-wide Language picker (Auto / English / Russian).
It persists to the backend config (
ui.locale) and is the only language switcher across browser, desktop, and the VS Code / IntelliJ plugins (seedocs/intellij-embedding.md). The default tab. - Appearance — theme only (see below), plus the "Restart onboarding" button.
The raw ui config key is hidden from the schema-driven tabs (HIDDEN_KEYS in
settingsSections.ts) so the language control is not duplicated; the key still
round-trips through the footer Save because the whole config doc is PUT back.
- Default: dark theme on first visit.
- Cookie:
foxxycode_ui_themewith valuesdarkorlight(path/,SameSite=Lax). ?theme=<id>query parameter (IDE embeddings): accepted values are all 7 theme ids; precedence query > cookie > default. Applied by the inline bootstrap script inindex.htmlbefore first paint and persisted to the cookie. Contract-tested inthemeCssContract.test.ts(the inlineVALIDmap must stay in sync withUI_THEME_IDS).window.foxxycodeUiglobal API (external/ui/src/ui/theme/foxxycodeUiApi.ts, installed inmain.tsx):setTheme/getTheme/getThemes/onThemeChange— lets a host (IntelliJ plugin via JCEFexecuteJavaScript) switch themes live. Seedocs/intellij-embedding.md.- Toggle: Settings (
#/settings) → Appearance → Dark / Light (data-testid="theme-toggle-dark",theme-toggle-light). - Settings sub-panels (Appearance / Skills) are mutually exclusive — opening one closes the other. Only one sub-panel may be expanded at a time.
- Persistence: switching theme writes the cookie and sets
document.documentElement.dataset.theme; reload must keep the chosen theme. - CSS contract:
--textand--bgon[data-theme="light"]are#18181band#f8f8fa; glass panels usergba(255, 255, 255, 0.9)(not dark tint). Dark defaults remain on:root/[data-theme="dark"].
- The first-run provider picker includes Codex. Selecting it replaces the API-key field with the same Sign In with ChatGPT device-flow card, keeps the optional proxy and model controls, and saves
type: codexwithout credentials in the config document. - After sign-in, Fetch models probes the Codex catalog using the server-side credential for the unsaved
codexprovider name; the browser never receives or resubmits the OAuth token. - In Settings → LLM Providers, a row with
type: codexhides the generic API base URL, API key, and API key command fields and renders Sign In with ChatGPT. - The button starts
POST /foxxycode/providers/{name}/codex-auth/device, opens the returned official verification page, displays the one-time code, and pollsGET .../device/{loginID}until completion or failure. The displayed link remains available if the browser blocks the automatic tab. - Connected state comes from
GET /foxxycode/providers/{name}/codex-auth. Sign Out deletes only the FoxxyCode-managed credential; a server-side Codex CLI login may still appear as a compatibility connection. - OAuth tokens never enter the settings document or browser. The server stores them under
$FOXXYCODE_HOME/providers/<name>/codex-auth.json.
Desktop layout
- Brand is typography only (FoxxyCode and agent). No circular logo or icon before the brand text, regardless of older reference images that include a circle.
- Desktop nav is a vertical panel with rounding on the right edge (not a full-height center-pill). On
min-width: 1920px, the wide rail header includes an icon with horizontal lines used only to collapse to narrow rail, not as a global navigation drawer. - Left rail opens chat history from History under the brand; brand click goes to the start screen (new chat).
- Brand, History, Scheduler (when linked), Settings, and each row in the History list use real fragment
hrefvalues (#/,#/history,#/scheduler,#/scheduler/new(new job editor),#/settings,#/s/<sessionId>) so middle-click or Ctrl/Cmd-click opens a new browser tab on the same origin while another tab can keep streaming. - Sessions list is always a drawer overlay with backdrop at all breakpoints and rail widths (no inline column beside the rail that would shrink the chat area). The panel heading and related chrome use the copy History.
- Picking a session closes the drawer only inside an editor plugin (
isEditorEmbed(), decided byshouldCloseHistoryOnSessionPickinsessions/pickSessionGuard.ts): an IDE tool window is narrow enough that the drawer covers the whole chat, so leaving it open reads as "nothing happened". Browser and desktop shells keep it open so several conversations can be browsed in a row. While the transcript loads, the skeleton shows a namedchat-skeleton-labelrow (sessions.loadingSession) rather than bare shimmer bars. - Project scope toggle (
sessions-project-only) renders in the drawer only when the caller passes a host project root — the folder the server was launched with, read once fromGET /foxxycode/workspace/contextwithout a session header (do not reuseworkspaceCtx, which follows the viewed session and would flip the scope when a foreign session is opened). When on, the list request carriescwd=<root>and shows only sessions in that folder or beneath it. Default is on inside an editor plugin and off in the browser, persisted per root inlocalStorage(sessions/sessionsProjectFilter.ts). A session running in a linked worktree outside the project root is only visible with the toggle off. - Optional rail narrow versus wide (icons plus labels) only when
min-width: 1920px, persisted infoxxycode_nav_railcookie (narrowdefault) - Main chat area with streamed assistant output
- Right rail is out of scope for the current milestone
Wide screens
min-width: 1920pxmay enable the rail widen control and cookie-backed layout (see DESIGN.md). History remains a floating drawer next to the measured nav column (--rail-shell-track-width); do not fixleftwith a static pixel constant for wide rails.
Mobile layout
- On mobile the left rail becomes a top bar to preserve horizontal space; the top bar is
position: fixedat the viewport top (shell-mainis padded with--foxxycode-mobile-top-inset) whilebodyscrolls the chat. - On mobile the brand stays on a single line.
Header links
- GitHub link to
https://github.com/hijera/foxxycode-agent(new tab,rel=noopener). - API docs link to
/docs/(new tab,rel=noopener). - Links live in the nav rail for this milestone.
Narrow-rail tooltips (desktop)
- When the rail has no wide labels, hover tooltips reinforce icon meaning (example New Chat on the brand, History on history). Wide labeled rail hides those tooltips; labels are the affordance.
- After opening History, the history trigger's tooltip must not stay visible if the pointer still hovers the rail (see DESIGN.md).
- Session id is generated client side only after the first message is sent from a new chat.
- Session id is persisted in the URL fragment.
- Recommended format
#/s/<sessionId>
- Recommended format
- Unsent composer text may be kept as a client-only draft session.
- Draft sessions use
#/draft/<draftId>and are stored inlocalStorageunderfoxxycode_draft_sessions_v1. - History rows show a
Draft:title prefix.
- Draft sessions use
- Session id is sent in the
X-FoxxyCode-Session-IDheader for chat transport. - Editor embeds reopen the project's last session. Inside a plugin webview (
?embed=…,isEditorEmbed()), an empty hash on load means continue where the user left off, not new chat: the SPA readsGET /foxxycode/project/last-sessionand routes to#/s/<sessionId>. Any explicit hash (#/s/…,#/draft/…,#/history,#/settings,#/scheduler) wins over the restore, and the desktop app and plain browser tabs are unaffected. The viewed session is recorded back withPUT /foxxycode/project/last-session; going back to a new chat records an empty id so that sticks too. Logic lives inexternal/ui/src/ui/sessions/lastProjectSession.ts; the record is per project in~/.foxxycode/projects.json, because plugins bind a fresh random port on every IDE launch and browser storage is keyed by origin. - Session id validation matches
internal/session/ValidateFolderSessionID. - Session persisted files live under the session directory and are deleted together when the session is deleted.
tool_calls/tool call historystats.jsontoken usage totals
- Several sessions may stream at once, each with its own
POST /v1/responsesandX-FoxxyCode-Session-ID. The app keeps a per-session shadow transcript so rapid hash switches do not mis-route SSE updates; seepickStreamMutationBaseinexternal/ui/src/ui/chat/streamMutationBase.ts. - Stop uses
POST /foxxycode/sessions/{id}/cancelandAbortSignalon the streamingfetch. The server persists partial assistantcontentfor that turn when tokens had already arrived.GET /foxxycode/sessions/{id}/messagesmay return an older snapshot briefly; the UI merges with local shadow or visible rows when the response is only a prefix (mergeTranscriptPreferLocalSuffix,keepLocalTranscriptIfServerEmptyinexternal/ui/src/ui/chat/transcriptServerSnapshot.ts). The transcript is cleared on fetch failure only when the failed load targets the currently viewed session so Stop does not wipe the chat.
Session title
- UI shows the session title in the chat header.
- When the title is missing, UI shows
New chat. - Title is editable inline. On blur the UI saves via
PATCH /foxxycode/sessions/{id}.
- New chat defaults Model from cookie
foxxycode_llm_model, thendefault_agent_modelfromGET /v1/models, then the first YAML row. - Opening a session restores Model from
GET /foxxycode/sessions/{id}/messagesfieldmodel(session override on disk), not from the cookie. - Changing Model writes the cookie (default for the next New chat) and
PATCHselectedModelIdon the active session. ReAct turns still sendmetadata.modelonPOST /v1/responses. - Many models / long names — backend ids are
vendor/model. When more than one vendor is configured the menu groups rows under an uppercase vendor header and each row shows only the model name (full id stays in the row tooltip). On desktop the list scrolls with a ~5-row cap. When there are more than 5 backends a filter input appears at the top (auto-focused) that matches the vendor, model name, or full id (case-insensitive); Enter picks the first match, Escape closes, and an empty result shows a “No models match …” notice. Filter/group/threshold logic is inchat/llmModelMenu.ts(unit-tested inllmModelMenu.test.ts; menu wiring covered byComposerModelMenu.test.tsx). - Mobile sheet — on narrow/mobile shells (the
max-width: 1199pxshell-stack breakpoint) the Mode / Model / Reasoning menus open as a full-width bottom sheet over a dimmed scrim — the same pattern as the slash (/) and@pickers — instead of a cramped anchored dropdown. The filter and grouping still apply inside the sheet. Desktop keeps the anchored dropdown.
- A Reasoning selector appears in the composer next to Model only when the active model exposes
reasoning_levelsfromGET /v1/models(reasoning models such as gpt-5 / o-series / Claude thinking models). Levels are derived frommodels[].reasoning_levels(auto-detected from the model id when unset) and propagated throughModelInfo.reasoningLevels→llmReasoningLevelsinApp.tsx→Composer. - New chat defaults the level from cookie
foxxycode_llm_reasoning, then the model'sreasoning_default, thenmedium(or the first offered level). Opening a session restores it fromGET /foxxycode/sessions/{id}/messagesfieldselectedReasoning. Switching to a model that does not offer the current level clamps it to a valid one (seepickReasoningLevelinchat/reasoningSelection.ts). - Changing the level writes the cookie and
PATCHselectedReasoningon the active session; ReAct turns also sendmetadata.reasoningonPOST /v1/responsesso a brand-new session applies it on the first turn.
- A chip row renders at the top of the composer card (
WorkspaceChips.tsx, helpers inchat/workspaceContext.ts): folder chip (workspace basename, full path in tooltip), branch chip (current git branch; only when the workspace is a git repository), a worktree checkbox, and — when an svn working copy is detected — an SVN chip plus its branch-folder checkbox. - Context loads from
GET /foxxycode/workspace/contextwithX-FoxxyCode-Session-IDwhenever the viewed session changes; without a session the server default cwd is shown. - Chosen once: folder + branch + worktree are set before the conversation starts. Once the transcript has messages the chips lock (
workspaceLocked— controls disabled, menus closed) and the server answers 409 toPOST .../workspace. - Folder chip opens the Recent menu (Claude Desktop style): MRU folders from
localStoragefoxxycode_workspace_recents_v1(chat/workspaceRecents.ts), current workspace marked with ✓, thenOpen folder…at the bottom which opens the folder browser modal (WorkspaceFolderModal.tsx) fed byGET /foxxycode/workspace/folders?path=: rows navigate into folders,..goes up, Open picks the currently browsed folder, Cancel dismisses. Picking callsPOST /foxxycode/sessions/{id}/workspace{"path"}— the session cwd switches and persists; skills, project rules, and slash commands re-derive from the new cwd. - Branch chip opens the branch list (current first, marked selected). Picking one posts
{"branch", "worktree": <checkbox>}: in-place checkout by default, a dedicated worktree under<home>/worktrees/<repo>/when the checkbox is on, or a jump to the worktree that already has the branch checked out (including back to the main checkout). - Worktree checkbox (
composer-worktree-checkbox, realinput[type=checkbox]) is the worktree preference; when the session already runs inside a linked worktree it shows checked and disabled. - SVN chip (
composer-svn-chip) renders next to the git chip wheneveris_svn_repois true. Git and Subversion are detected independently, so a branch folder checked out from SVN that also holds a git repository shows both chips and each switches only its own VCS. The chip label is the svn branch (trunk,branches/<name>), with URL and revision in the tooltip; the menu lists the current branch first, thentrunk, then the rest. Picking one posts{"branch", "worktree": <checkbox>, "vcs": "svn"}. - SVN branch-folder checkbox (
composer-svn-folder-checkbox) replaces the worktree idea for Subversion, which has none: off switches the working copy in place (svn switch), on checks the branch out into its own folder under<home>/worktrees/<wc>/(reusing an existing checkout) and moves the session there — the branch-folder workflow. - With
vcs.svn.enabled: falseor no svn client installed,is_svn_repois false and neither svn chip renders. - Pre-session (draft/home): picks are stored client-side, previewed via
GET /foxxycode/workspace/context?path=, and applied to the new session id on first send beforePOST /v1/responses. Switching to another session drops pending picks. - Errors (missing folder 400, git conflicts / locked workspace 409) keep the current chips; the context is re-fetched to stay truthful.
- Automated checks:
chat/workspaceContext.test.ts,chat/workspaceRecents.test.ts(helpers),chat/WorkspaceChips.test.tsx(chips, menus, modal, lock); backend behavior is specified executable infeatures/workspace_switching.featureandfeatures/svn_workspace.feature(godog).
- History panel lists sessions via
GET /foxxycode/sessions(still a drawer, not a persistent second column). - Pagination uses
limitandcursor, with infinite scroll for older rows. - Optional
qquery string (title substring or firstusermessage content substring only, case insensitive; not full-chat search). Search input updates use client debouncing. - Indicators
- A spinner appears on rows for sessions that are still generating in the background.
- A violet dot appears only when a background session completed while it was not the active chat.
- A question mark icon appears when a session is waiting for user permission.
- CRUD
- Rename via
PATCH /foxxycode/sessions/{id}settingtitle. - Delete via
DELETE /foxxycode/sessions/{id}. - Create new chat starts on the home screen. Session id is created only on first send.
- Rename via
Session rename UX
- Title rename is done only in the chat header.
- On blur the UI saves via
PATCH /foxxycode/sessions/{id}.
Session delete UX
- Each row has a trash icon button.
- Clicking delete shows one confirm dialog and then calls
DELETE /foxxycode/sessions/{id}. - If the deleted session is not the one currently shown in the main chat, remove it from the list (and refresh from the server) and keep the History drawer open. Do not change the URL or clear the transcript for the session that stayed on screen.
- If the deleted session is the one currently shown, navigate to new chat (empty start screen, session hash cleared), close the History drawer, and clear composer-related state as for a normal home transition.
- For a short interval after the user confirms delete, ignore shell backdrop pointer-driven close so a stray event from the native confirm does not dismiss History or alter the route.
- Primary transport is
POST /v1/responses. stream: trueuses SSE.
Mode selection
- UI lets the user select the FoxxyCode profiles
agent,plan,docs, andaskfromGET /v1/models. - Selected mode is sent as
modelfield inPOST /v1/responses. - Ask uses the green mode outline and remains non-mutating. Settings → Tools → Disable extended Ask tools is a schema-driven checkbox for
tools.ask_disable_extended_tools; it is off by default. When enabled, Ask retains repository read/search/tree, question, and skill tools but hides shell, MCP, web, and scheduler inspection.
SSE payloads
- Default SSE lines stream OpenAI like deltas.
- Named SSE events
tool_calltool_call_updateplantoken_usageusage_update(used/sizefor the current model context; emitted again after compaction)- Default (no
event:): chat completion chunk deltas, includingdelta.contentand optionaldelta.reasoning_content
Context ring and breakdown popover
- Hover on
.composer-context-tip-host: compact tooltip (percent, input/output/total, max context) unchanged. - Click opens
ContextBreakdownPopoverbeside the ring on wide viewports (context-breakdown-menu--portal); on stacked shell (max-width: 1199px) it uses the same bottom sheet + scrim as slash /@pickers (context-breakdown-menu--sheet,slash-sheet-backdrop). Escape or Close dismisses; hover tooltip returns when closed. - Legend keys map to
contextBreakdownonGET /foxxycode/sessions/{id}/stats(systemPrompt,toolDefinitions,rules,skills,mcp,subagents,conversation,summary). Liveusage_updateSSE replaces the displayed total immediately (including after/compactor automatic compaction), then the UI refreshes the detailed stats. Vitest:Composer.test.tsx(click context ring opens breakdown popover) andconsumeComposerSseOrder.test.ts(usage_update replaces the displayed current context after compaction).
Shape and glyphs
- The control sits to the right of the context ring (
.composer-icononComposer.tsx). - The hit target is a perfect circle: equal width and height,
border-radius: 50%,box-sizing: border-box(currently 42×42px instyles.css). Do not ship a rounded square or squircle for this control unless the visual spec explicitly changes again. - Play (idle, draft non-empty): Unicode triangle
▶, enlarged vs body text (~22pxglyph viacomposer-send-glyph), slight horizontal nudge for optical centering. - Stop (while streaming): filled square
.composer-stop-square(14x14px, centered in the 42px circle). Stays incomposer-bar-actionson the right, next to the context ring. - Disabled idle state when textarea is whitespace-only (
:disabledoncomposer-send-play).
Behavior (unchanged summary)
- Enter submits when idle and not generating;
Shift+Enternewline. No submit whilegenerating. - Stop:
POST /foxxycode/sessions/{id}/cancel+fetchAbortSignal. The server may append a partial assistant message for that turn.GET /foxxycode/sessions/{id}/messagescan lag; the bundled UI merges server rows with local shadow or on-screen items (transcriptServerSnapshot.ts). Details inDESIGN.md(Multi-session streaming and Stop) anddocs/http-api.md.
Regression
- Automated UI checks (Playwright MCP or
@playwright/test) MAY assert#btn-sendoffsetWidth≈offsetHeightand computedborder-radius≥ halfmin(width,height)(within sub-pixel tolerance).
- The paperclip button (
data-testid="composer-file-input"hidden<input type="file">triggered by a visible icon button) appears in the composer only when the active model hasmultimodal: truefromGET /v1/models. The flag is derived frommodels[].multimodalin YAML config and propagated throughModelInfo.multimodal→llmModelMultimodalinApp.tsx→Composerprop. - Attached files are held in
attachedFiles: File[]state onComposer. Preview chips appear above the composer input showing file name and type icon. - On send,
App.tsxreads each file as a data URL viaFileReaderand includesinline_files: [{name, data_url}]in thePOST /v1/responsesbody. - Agent / plan turns: the server writes each file to
~/.foxxycode/sessions/<id>/assets/(permissions0o444) and injects a<foxxycode_session_assets>XML block into the user message so the agent canreadorcpthose paths. Duplicate asset names get_1,_2suffixes (seeinternal/session/assets.goSavePartsToAssets). - Direct YAML model turns: each file becomes an
image_urlcontent part sent inline to the provider. - The user bubble strips the XML annotation via
stripFoxxyCodeAttachmentsForUserDisplayinstripFoxxyCodeAttachments.tsand shows file chips (msg-user-files/msg-user-file-chipCSS classes).parseSessionAssetFilesre-derives chip metadata on page reload. - After a
PUT /foxxycode/configsave in Settings,App.tsxbumpsmodelsEpoch→ re-fetches/v1/modelsso the attachment button appears or disappears without a page reload.
| Case | Expected | Automated check |
|---|---|---|
| FA1 | Paperclip visible only when llmModelMultimodal is true |
Composer.test.tsx |
| FA2 | File chips render in user bubble after send | stripFoxxyCodeAttachments.test.ts |
| FA3 | Chips persist on reload via parseSessionAssetFiles |
stripFoxxyCodeAttachments.test.ts |
Authoritative narrative and visual tokens live in DESIGN.md (slash picker, mirror contract, verification table). This section is the functional contract for regression.
Wire and draft
textarea#composerholds plain text only. Invoked skills appear as/<name>tokens (space after picker selection). The UI must not persist[/<name>](foxxycode-skill:<name>)in the draft.- First user turn on
POST /v1/responsescarries the same plain slash tokens as the composer value (no client-side markdown injection for skills in the request body).
Picker and segmentation
- Menu visibility and
prefixderive fromslashMenuDraftAtCaretinexternal/ui/src/ui/skills/draftSlash.ts(line-start or whitespace before/, optional suffix, not inside fences or blockquotes). - Mirror highlighting uses
segmentComposerSlashSpansinexternal/ui/src/ui/skills/segmentComposerSlashSpans.ts(mid-line/supported;x/foois not a command token).
Mirror and caret alignment
- Non-empty drafts: textarea text is drawn transparent;
.composer-mirror-innershows the visible line including.composer-skill-chip-inline(data-testid="composer-skill-chip"). - Composer chips must not use horizontal padding, margin, or a border that changes inline width. Use
box-shadowfor outline.font-family,font-size,line-height,font-weight,letter-spacingon chip and#composermust match so the caret lines up (ResizeObserversyncs scrollbar gutter).
Transcript vs composer
user_messagebubbles render plain text only (msg-user-body,white-space: pre-wrap). No Markdown pipeline, no transcript skill chips (foxxycode-skill-span). Slash tokens such as/path/toand YAML blocks stay exactly as persisted, with line breaks preserved.- Composer mirror chips (
composer-skill-chip) apply only while editing#composer, not in the transcript. - Persisted user turns may carry hydrated attachments as
foxxycode_attachmentXML withpath,name, and CDATA file bodies (internal/agent).stripFoxxyCodeAttachmentsForUserDisplayreplaces each XML block with a compact@pathonly when that path is not already present as an@mention in the surrounding text (avoids duplication because the persisted turn already repeats the@in the user text plus the hydrated block).
Verification use cases
| ID | Expectation | Primary automated check |
|---|---|---|
| UC1 | One chip for asdfasf /find-skills asdfasdf, plain textarea.value |
external/ui/src/ui/chat/Composer.test.tsx (composer highlights plain slash token as chip while editing) |
| UC2 | Mid-line menu open after whitespace | draftSlash.test.ts (slashMenuDraftAtCaret works after whitespace mid-line) |
| UC3 | x/foo no chip for /foo |
segmentComposerSlashSpans.test.ts (segmentComposerSlashSpans skips letter before slash) |
| UC4 | Line-leading /foo chip |
segmentComposerSlashSpans.test.ts (segmentComposerSlashSpans line start slash) |
| UC5 | stripFoxxyCodeSkillMarkdownLinks on legacy paste |
segmentComposerSlashSpans.test.ts (stripFoxxyCodeSkillMarkdownLinks restores plain slash token) |
| UC6 | User bubble keeps hi /demo there plain (no foxxycode-skill-span) |
UserMessage.test.tsx |
| UC7 | Multiline YAML / paths keep \\n layout in user-message-body |
UserMessage.test.tsx |
| UC7b | Display-only slugSlashes (plain / and legacy mix) |
segmentComposerSlashSpans.test.ts (slugSlashesForUserBubbleMarkdown …; composer / legacy only, not transcript) |
| UC8 | Live foxxycode http: fontFamily parity chip vs #composer, caret selectionStart === value.length at EOL after fill |
Playwright MCP browser_evaluate after make build TAGS="http ui" |
| UC9 | User bubble hides foxxycode_attachment bodies, shows @path only |
UserMessage.test.tsx, stripFoxxyCodeAttachments.test.ts |
textarea#composerkeeps plaininputincluding literal@pathtext.POST /v1/responsesaddsattachments(pathonly) parsed byextractAtFileAttachmentsinexternal/ui/src/ui/skills/draftAt.tsforagent/plan/docs/ask. Server-sideHydratePromptContentBlocksusesExtractAtFilePathsFromText(internal/session/at_paths_extract.go) after filling emptyresourcebodies so@pathliterals insidetype: textblocks become extraresourcerows when that path is not already hydrated (matches HTTPattachmentswithout duplicating).@menu usesGET /foxxycode/workspace/fileswithdirs=truesokinddirrows drill down. Choosing adirinserts@+path_rel(often ending in/) without hydrating file body. Choosing afileinserts@+path_relplus a trailing ASCII space where appropriate.Composerdefers twoupdatePickerMenusticks after a row choice so the workspace dropdown does not immediately reopen (trailing space andMENU_PATH_CHARstill satisfyatMenuDraftAtCaretuntil the user edits again).- Empty
@prefix (caret right after@) loads recent rows fromlocalStorage(workspaceAtRecents), keyed bysessionId(or__no_session__before the first assigned id), with no extra banner line (Type after @ to searchonly when the list is empty). Entries come from@row picks andextractAtFileAttachmentson successful profile sends (migrateWorkspaceAtRecentsmerges when the client generates or the server rotatesX-FoxxyCode-Session-ID). - Fenced code blocks and Markdown blockquote lines suppress
@menu parity withdraftSlash(inMarkdownFenceBeforeCaret,blockquoteLine). - Mirror
@styling usessegmentComposerMirrorSpans(composer-at-chip-inline,data-testid="composer-at-chip").listAtPathSpans(draftAt.ts) chips every completed@pathatom even when prose follows (draftAtparity withextractAtFileAttachments), while text after the caret that is still insideMENU_PATHstays on the active token until theatMenuDraftAtCaretlexer breaks out. @search with zero matches keeps the picker open (No files) instead of collapsing the menu (composer-at-chip-inlinehides foratNoMatch, sameatIdx,prefixas the stale filter).- Stacked-shell viewports (
(max-width: 1199px)) render workspace and slash pickers as aslash-menu--sheetwithslash-sheet-backdropso the panel is usable on phones. - Picker subtitle uses
workspacePickRowSubtitle- second column showsparent/only whenpath_relis nested, root entries omit it (empty string).
| Case | Expected | Automated check |
|---|---|---|
| AT1 | Spaces inside paths ( readme copy.md ) work in picker draft and hydrate when attached |
draftAt.test.ts, session/promptfiles_test.go (hello world.txt) |
| AT2 | Prefix substring filter (case-insensitive), empty prefix returns empty items on server |
TestFoxxyCodeWorkspaceFilesGetPagingAndPrefixes |
| AT3 | Prose see @note.txt does not merge and into the path segment |
draftAt.test.ts (extractAtFileAttachments connector words) |
| AT4 | @ inside session/prompt text alone still hydrates (no duplicate when attachments or resource already has body text) |
TestHydratePromptContentBlocksExpandsAtInText, at_paths_extract_test.go |
| AT5 | Picker second column shows parent/ for nested path_rel, empty at workspace root (workspacePickRowSubtitle) |
workspacePickRowSubtitle.test.ts |
The chat transcript renders a flat list of UI message blocks. Each block has a type and a minimal set of required fields.
user_message- Plain user input text (no Markdown;
pre-wrappreserves line breaks).
- Plain user input text (no Markdown;
thinking- Renders model reasoning as a lightweight disclosure row.
- Status
in_progressshows labelthinking...and a spinner. - Status
completedshows labelthinkingand preserves the text for review. - Multiple
thinkingblocks may appear in one turn (reasoning can resume after tool calls).
tool_call- A single tool execution row, same disclosure chrome as thinking / memory (chevron,
thinking-labelwith the tool name or kind,thinking-durfor duration or-). - While
pendingorin_progress, the summary label uses a...suffix (for exampleread_file...).startedAtMsdrives a live duration until the tool finishes. - When a structured preview and Result are both present, they touch and share the outer corners as one continuous execution card; there is no gap or duplicate border between them.
- Details reuse the permission card's tool-specific preview in a static mode: full diff / path / command content, but no copy, More…, or approval actions. read, grep, glob, and print_tree also receive compact structured argument previews; unknown tools keep a styled monospace fallback. The separate Result body is plain text only (rendered like
<pre>, no Markdown pipeline). IfresultPreviewTruncatedis false /resultWasTruncatedunset, there is no overflow toggle or fixed-height viewport (block height follows content). If truncated (19 content lines plus...), apply the capped viewport (~20 lines), with overflow-y hidden until More…. More… (data-testid="tool-result-more") performs GET/foxxycode/sessions/{id}/tool-calls/{toolCallId}, then enables overflow-y auto at the same height and becomes Less (data-testid="tool-result-less"); Less restores the clipped preview without a second GET while fullResultText stays in memory. Both use the shared left-alignedtool-overflow-toggletab button attached flush to the result panel's bottom border.
- A single tool execution row, same disclosure chrome as thinking / memory (chevron,
Authoritative behaviour matches DESIGN.md tool timeline plus this checklist.
| Concern | Current behaviour |
|---|---|
| Component | ToolCallMessage.tsx - thinking-row foxxycode-tool-call-row, details.thinking-details.foxxycode-tool-details, data-testid: tool-details-{toolCallId} |
| Summary | Same pattern as thinking (thinking-summary, thinking-left, thinking-chevron, thinking-label, thinking-dur), aria-label="Tool summary" |
| Args | pre.tool-block, aria-label="Tool arguments" (inside thinking-body foxxycode-tool-call-body) |
| Result | div with tool-block tool-result tool-result-raw, aria-label="Tool result", inner pre.tool-result-pre |
| Markdown | Not used for tool result or user bubbles; assistant still uses Markdown per below |
| List merge | App.tsx loadMessages merges GET /foxxycode/sessions/{id}/tool-calls rows into resultText, resultWasTruncated, timing |
| Full text | First More… only - GET /foxxycode/sessions/{id}/tool-calls/{toolCallId}, use JSON result (same object includes meta, args) |
| CSS | styles.css: .foxxycode-tool-call-row, transparent .foxxycode-tool-call-body, shared .permission-preview*, .tool-call-result-card, thinking-details:not([open]) body hidden, plus result viewport / toggle classes above |
assistant_message- Final assistant output text for the turn, after tool calls.
The inline approval gate is implemented by PermissionPromptSection and PermissionPromptPreview.
- Render the card only for a pending permission request. Read-only tools render their normal timeline row only; there is no informational no-approval card, checkmark, or explanatory sentence.
- Header: human action question plus one raw tool-id badge. The preview header is reserved for the path, shell, or operation scope so the tool name is not duplicated. The desktop notification toast reuses the same question text.
- Actions follow the server-provided option list and order (Allow, Allow always, optional Always allow
<program>, Reject); a fourth button needs no client change beyond layout. Labels are localized byoptionIdinchat/permissionOptionLabel.tsrather than rendered from the backend's English text. - The program-wide option only reaches the client for run_command on a single plain invocation. The backend label already names the exact grant (
curl,git status), so the program name is carried through the translation verbatim rather than re-derived. - Match the prompt to its tool_call by toolCallId and prefer that row's argsText; fall back to Arguments: content in the permission payload.
- apply_patch and edit render old/new line gutters and theme-aware added/deleted/context rows. Other filesystem mutation tools and run_command use compact structured previews rather than JSON.
- The collapsed preview is measured after layout. Show More… only when scrollHeight > clientHeight; keep the viewport bounded, switch it to internal vertical scrolling, and change the button to Less. Returning to the collapsed state restores clipping and re-measures overflow. The shared button is left-aligned; on phones it has a 36px minimum height.
- Restored write permission prompts include rm and rmdir alongside the other filesystem mutation tools.
- All question / header / metadata strings come from
t()withen.ts+ru.tsparity, so the card renders in the active UI locale.
Automated checks:
- external/ui/src/ui/chat/permissionToolPreview.test.ts
- external/ui/src/ui/chat/PermissionPromptSection.test.tsx
- external/ui/src/ui/chat/permissionPromptPreviewCss.test.ts
- external/ui/src/ui/messages/MessageList.test.tsx
- external/ui/src/ui/messages/toolCallConnectedResultCss.test.ts
The panel is docked inside the session, to the right of the transcript (.bgtasks-panel), not a shell drawer: a task belongs to the chat that started it. Routes are #/s/<sessionId>/tasks and #/s/<sessionId>/tasks/<task_id>, so a reload restores the chat and the panel together; closing writes #/s/<sessionId> back. Backed by /foxxycode/sessions/{id}/background-tasks* (see docs/background-tasks.md).
- It polls rather than listening on SSE, because a background task outlives the turn that started it: every 2.5s while anything runs, every 15s otherwise. A poll against an unreachable server yields a normal error result, never an unhandled rejection.
- Running is a section of cards (status dot, command, elapsed against the estimate, Stop). A progress bar appears only while running and when the model supplied
expected_seconds. - Finished N is a counter; expanding it lists one line per task, capped at 40 rendered rows with a note naming what stays on disk. Clear drops the finished history for the session.
- Ordering is purely by start time, newest first, in both sections.
- The opener is a chip at the end of the transcript (under the last message, above the composer), not a nav rail entry:
N running taskswhile work is in flight,N background tasksotherwise, and nothing at all in a chat that never ran one. - On
max-width: 1199pxthe panel takes the screen and finished rows grow to a 40px touch target. - A transcript
run_commandrow that started a task keeps a live chip in its collapsed summary and gains Open in Tasks / Stop when expanded, driven by the same poll.
Automated checks:
- external/ui/src/ui/tasks/taskStatus.test.ts (timing, progress, overdue, poll cadence, start-time ordering, grouping)
- external/ui/src/ui/tasks/BackgroundTasksPanel.test.tsx (sections, finished counter, Clear, detail pane, empty and error states)
- external/ui/src/ui/tasks/api.test.ts (paths, headers, offline degradation)
- external/ui/src/ui/tasks/BackgroundTasksChip.test.tsx (counts, singular/plural, history fallback, empty chat)
- external/ui/src/ui/tasks/backgroundTaskCss.test.ts (chip tokens, panel docking, reduced motion)
- external/ui/src/ui/messages/ToolCallMessage.test.tsx (transcript ticker chip)
- UI must show token counters while the agent is working.
- Counters update when SSE event
token_usagearrives. - Update granularity is per completed backend model call, not per generated token.
- UI restores token counters after restart via
GET /foxxycode/sessions/{id}/stats.
- Tool outputs are excluded; they stay raw monospace text (
ToolCallMessage). - User messages are plain text with preserved line breaks (
UserMessage). - Assistant messages may contain Markdown.
- UI renders Markdown with fenced code blocks and syntax highlighting.
- Each code block has a copy button that copies only that block content.
Implemented as MarkdownLineEditor (external/ui/src/ui/markdown/MarkdownLineEditor.tsx). Used for:
- Scheduler job
body (markdown)(SchedulerJobEditorSheet, defaultminRows10). - Plan document card markdown mode (
PlanDocumentSection,minRows4, classmd-line-editor--plan).
Behaviour (see DESIGN.md, Markdown line editor):
- Full parent width; editor height follows content (minimum logical rows); no scrollbar on the inner
textarea. - Gutter shows one number per logical line (
\n-separated). Wrapped visual lines leave blank gutter cells (no duplicate numbers). - Caret logical line: highlight spans all visual rows of that line; active gutter number tinted.
- Wrap measurement uses a hidden probe with the same font and text width as the textarea; visual rows =
ceil(height / lineHeight). - Long unbreakable tokens wrap (
overflow-wrap: anywhere); no horizontal scroll inside the editor.
Automated checks:
external/ui/src/ui/markdown/MarkdownLineEditor.test.tsxexternal/ui/src/ui/markdown/markdownLineGutter.test.ts
Transcript type plan_document renders PlanDocumentSection in the main chat column (not a right rail).
Data and API:
- Persisted in
messages.json; hydrated fields includeslug,name,overview,content, optionalbody,path,discarded. - Live during a turn: SSE
event: planwhose_metaholdsfoxxycode.dev/planKind: designandfoxxycode.dev/planSlug; the SPA then loads the document fromGET /foxxycode/sessions/{id}/plans/{slug}and upserts the card by slug. - Body edit:
PUT /foxxycode/sessions/{id}/plans/{slug}with{ "body": "<markdown>" }(debounced autosave). - Discard:
DELETE /foxxycode/sessions/{id}/plans/{slug}setsdiscarded: true; card remains visible, controls disabled. - Run plan: client triggers implementation run (metadata / prompt; see
docs/acp-protocol.md).
UI requirements:
- Always the last row of its turn, below the assistant text that introduces it (
pinPlanDocumentsToTurnEnd), during streaming and after the transcript rebuild. The server emits the row mid-turn, so message order alone would put the card above the answer. - Rebuilds keep the card's identity by slug: the expanded state survives, and an unsaved markdown draft is not overwritten while a save is pending.
- Collapsed: title, one-line description, Discard and Run plan in footer; title
titletooltip = absolute plan file path when known. - Expanded: Preview default (rendered markdown via
Markdown); eye toggle switches toMarkdownLineEditor. - Content pane grows with document length for both preview and markdown (no inner max-height scroll on the pane).
- Expanded desktop (
min-width: 640px): title row and action buttons share the top row; body full width below. - Editor body excludes YAML frontmatter (client
planEditorBody); preview uses the same body text.
Automated checks:
external/ui/src/ui/chat/PlanDocumentSection.test.tsxexternal/ui/src/ui/chat/planDocumentPlacement.test.tsfeatures/plan_card_placement.feature(godog steps inexternal/httpserver/bdd_plan_card_test.go)
- Optional right-rail plan entries (if present in a build) use
GET /foxxycode/sessions/{id}/plan,PUT,POST .../plan/archive. - Distinct from the
plan_documenttranscript card above.
Memory tree roots
globalworkspace
Tree API
GET /foxxycode/sessions/{id}/memory/tree- Without
rootreturns the roots list. - With
rootand optionalpathlists children.
- Without
- Only
.mdand.txtfiles are listed. - Path traversal must be rejected.
File API
GET /foxxycode/sessions/{id}/memory/filereads.PUT /foxxycode/sessions/{id}/memory/filewrites.
Functional checklist for the Settings -> MCP servers tab (MCPSection.tsx,
section kind mcp; visual contract in DESIGN.md):
GET /foxxycode/mcpbacks the list: mergedconfig.yaml+ global~/.foxxycode/mcp.json- project
./.foxxycode/mcp.jsonservers, each withsource(global/localscope badge),origin(config/home/project— drives the badge tooltip naming the owning file),readonly(config.yaml entries), probestatus, and its tool inventory.
- project
- Status dot per server: connected (green), error (red, tooltip shows the probe
error), disabled (gray), unknown transport type (amber,
unsupported). - Server switch toggles
POST /foxxycode/mcp/{name}/enable|disable; the change persists into the file that defines the server. - Expanding a row lists tools with per-tool switches
(
POST /foxxycode/mcp/{name}/tools/{tool}/enable|disable); tool switches are locked while the server is disabled. - Edit and Delete are locked for
readonly(config.yaml) rows; mcp.json rows of both scopes stay editable. Delete callsDELETE /foxxycode/mcp/{name}, Edit opens the JSON editor card inline with the scope pinned to the owning file. - Add server opens the editor prefilled with a Cursor-style entry template and
a Local/Global scope picker (default Local); Save issues
PUT /foxxycode/mcp/{name}?scope=local|globalafter client-side validation (mcpServerJson.ts: JSON object,commandorurlrequired, name without__, spaces, or path separators). - Refresh re-probes all servers via
GET /foxxycode/mcp?refresh=1. - An MCP discovery fieldset above the list carries
mcp.project_trust(POST /foxxycode/mcp/project-trust, valuesask/allow/deny). It never joins the settings-document Save all flow. - A project-local server the workspace trust gate holds back shows
statusneeds_approval(amber dot), exposes no tools — it is reported, not probed — and opens a note listing the declaration an approval would cover: transport, the command with arguments or the URL, env and header names (never their values), and the workspace.deniedrenders the same place with the policy explanation instead. - The per-server shield toggles
POST /foxxycode/mcp/{name}/trust|untrust. It renders only under theaskpolicy, sinceallowanddenyleave no per-server decision to make. - List refreshes never unmount the list (initial-load-only placeholder), so the drawer scroll position is preserved.
- The tab does not participate in the settings document Save all flow.
- Swagger UI is served under
/docs/. - OpenAPI spec is served under
/openapi.yamland/openapi.json. - Swagger UI assets must be embedded, no CDN.
- Edit TypeScript sources under
external/ui/src/. - Use
npm --prefix external/ui run devto iterate without rebuilding the Go binary. - Build and sync embed assets with
npm --prefix external/ui run build:go. make build TAGS="http ui"runs the UI build step (make ui-build) before linking the embedded bundle.
Store the provided design reference images under docs/assets/.
When describing a specific element, link to the relevant image file.
- Full HD UI tour (README):
docs/assets/screenshot-fullhd-start.png,screenshot-fullhd-chat.png,screenshot-fullhd-history.png,screenshot-fullhd-scheduler.png,screenshot-fullhd-settings.png - Mobile UI tour (README):
docs/assets/screenshot-mobile-start.png,screenshot-mobile-chat.png - Home layout:
docs/assets/ref-home-1.png,ref-home-2.png,ref-home-3.png - Home scroll state:
docs/assets/ref-home-scroll.png - Composer state:
docs/assets/ref-home-composer.png - Left rail icon states:
docs/assets/ref-rail-states.png - Chat history view:
docs/assets/ref-history.png - Chat transcript view:
docs/assets/ref-chat.png - Flow montage:
docs/assets/ref-flow.png
These scenarios are intended to be automated via Playwright against the Vite dev server.
Run npm run test:layout from external/ui for the bounded 390px/1280px
column-alignment smoke. The test owns Vite and headless Chrome, waits at most
10 seconds for readiness, limits the browser phase to 20 seconds, and cleans up
both processes with headroom under a 45-second outer timeout.
-
Desktop navigation has no width toggle
- Given viewport width is at least 1024px
- When the app loads
- Then
data-testid="nav-menu"is visible - And
data-testid="nav-toggle-width"is not present
-
Sessions are drawer only
- Given any desktop viewport
- When the app loads
- Then
data-testid="sessions"is not visible - When user clicks
data-testid="nav-menu" - Then
data-testid="sessions"becomes visible - When user clicks
data-testid="sessions-close" - Then the sessions drawer is hidden
-
Mobile uses top bar and single line brand
- Given viewport width is at most 1199px
- When the app loads
- Then the nav width toggle is not present
- And the nav rail height is 78px
- And sessions can still be opened from the menu button
-
Tool calls survive restart
- Given a session has tool calls executed
- When the user reloads the page
- Then tool call cards are visible in the transcript
- And expanding a tool card shows a structured args preview and a separate raw Result panel, without approval buttons
- And if the server marked the preview truncated, More… then Less behave as in the table above; if not truncated, there is no overflow-toggle row and no
tool-result-viewport--tallon the result panel
-
Tool result truncation (Playwright MCP)
- Given a persisted session whose tool output on disk exceeds the preview line cap
- When the user opens the tool card and clicks More…
- Then the button becomes Less, full lines are available inside the same max-height scrollable panel, and
.tool-result-viewport--scrollhasscrollHeightgreater thanclientHeight - When the user clicks Less
- Then the preview shows the capped text ending in
...,overflow-yis hidden on.tool-result-viewport--clip, and More… appears again
-
Token usage survives restart
- Given a session has non zero token usage
- When the user reloads the page
- Then the token usage HUD shows the persisted totals
-
Memory copilot row (Playwright MCP)
- Given
memory.enabled: trueon thefoxxycode httpprocess and at least one Markdown file under global or workspace memory so recall can run - When the user sends a chat message that completes a full ReAct turn
- Then an element with
data-testid="memory-copilot-row"appears after that user bubble for the turn (grey memory foldout, same visual language as thinking perDESIGN.md) - When the user opens the details element
- Then the streamed memory body shows the text merged into the main agent prompt for that turn (and optional saved-note preview when the copilot wrote
foxxycode_memory_save)
- Given
For Playwright MCP against a live gateway, start make build TAGS="http ui" then ./build/foxxycode http with a disposable --home so config can enable memory; open http://127.0.0.1:<port>/, navigate to a session, send a prompt, assert the snapshot contains memory-copilot-row and folded body text after expand.