The chat page served at /chat is rendered by tasks/io/serve_chat_ui.py
from a Jinja2 template tree under tasks/io/chat_ui/templates/. Plan and
rationale: CHAT_UI_TEMPLATE_PLAN.md.
from tasks.io.serve_chat_ui import render_chat_page
html = render_chat_page(agent_path="/api/agent", sse_path="/api/agent/events",
login_url="", theme_block="", extensions_block=None,
template_slots=None, custom_css="")-
One
jinja2.Environmentper process:FileSystemLoader(chat_ui/templates),autoescape=True,undefined=StrictUndefined,auto_reload=True,trim_blocks+lstrip_blocks,keep_trailing_newline(an include keeps its final newline, so partial boundaries never glue two lines). A changed partial is re-read on the next request (hotpatch workflow). -
The page is rendered per request; the context differs per request (theme cookie, installed extensions). The i18n boot block — the only costly piece — is cached per selected language and i18n file signature. Initial HTML embeds English plus the selected language; additional catalogs load asynchronously (see
PERFORMANCE_STARTUP_IMPLEMENTATION.md). -
Context passed to
chat.html:Key Type Meaning asset_versionstr 8-char aggregate asset signature retained for the SSE hotpatch/reload signal asset_versionsdict Per-file SHA256 content hashes (16 hex characters), used as ?v=on scripts, stylesheets and locale URLslazy_urls,i18n_urlsdict Versioned URLs for optional modules and asynchronous locale requests js_moduleslist Eager _JS_MODULESentries that exist on disk, in load order; excludes_LAZY_JS_MODULESandplans_panel.jscss_moduleslist _CSS_MODULESentries, in cascade orderi18n_blockstr, ` safe` theme_blockstr, ` safe` extensions_blockstr, ` safe` template_slotsdict {slot: [fragment html, ...]}— the enabled PFP packages' server-rendered fragments (see below)agent_path,sse_path,login_urlstr serveChatUI task parameters, emitted with tojsoninside<script>custom_cssstr serveChatUI custom_css(+custom_css_file), emitted as<style id="custom-css">after the CSS modules,</styleneutralisedOnly the server-built blocks are inserted with
|safe, visibly in the skeleton; every other value is autoescaped.StrictUndefinedturns a missing key into an error instead of an empty string. -
The asset signature (
_asset_signature()) coverstemplates/**/*.html,css/*.css,_JS_MODULES, pinned vendor assets andi18n/*.json. It is shared for one second; changed file metadata triggers rehashing. Editing a template changes the aggregate reload signal while unchanged assets retain their individual cache keys. Explicit invalidation forces a fresh manifest. -
RxJS and highlight.js load from local files with
deferbefore their consumers. The usage dashboard loads on first opening. Vendor license text files ship in the wheel and source distribution; generatedgraphify-outcaches are excluded from package discovery and from every package's data.MANIFEST.inalso prunes them from source distributions, including entries retained by an olderSOURCES.txt. When changing package selection, build from a freshbuild/libdirectory to avoid carrying old wheel contents. -
Renderer, templates and startup assets must activate together through a coordinated restart: the new templates require the new Python context keys.
Scheduled wakeup messages render as a single muted 11 px wake up separator
in both chat views, including history replay. The full original message is
available in its hover tooltip and accessible label; its stored content,
message identity, chronological position and turn boundary remain unchanged.
Header status popovers retain their original IDs and controls but move to
document.body when opened. pfFloatingLayer owns their viewport placement,
outside-pointer and Escape dismissal, and resize/scroll cleanup. Its standalone
controller loads before state.js, so header handlers always have it available
as soon as they become callable, including during delayed startup requests.
Closing the header also closes its popover. The active-agent rows wrap long names and tool
labels; narrow screens use the viewport width with an 8 px margin. Popovers sit
above the workspace and below modal dialogs without raising the entire .main
stacking context, which would intercept sidebar and task-rail clicks when an
atmosphere background is enabled. Browser coverage lives in
tests/test_header_popover_browser.py.
tasks/io/chat_ui/templates/
chat.html # skeleton: doctype, <head>/<body> structure, includes, extension points
head/styles.html # <link> per CSS module (cascade order) + the operator custom_css <style>
head/vendor.html # rxjs UMD, highlight.js + its DOMContentLoaded bootstrap
sidebar/sidebar.html # sidebar grip, #sidebar, Conversations section (+ new/import menu)
sidebar/resources.html # #resourcesPanel, resources_collection/resources_panel/sidebar_* slot hosts
dialogs/appearance.html # inherited/global or conversation appearance controls
dialogs/search.html # Ctrl/Cmd+K conversation-search overlay
dialogs/conversation_settings.html# #conversationSettingsDialog (expiry, sharing, controls)
header/tab_bar.html # #tabBar, tab_bar slot host, audio tab buttons
header/header_bar.html # header grip, #headerBar: logo, status, gauges, active agents, user info
header/action_dock.html # #actionMenuWrap: action menu items, action_menu/header_actions/gear_menu hosts
chat/panels.html # confirmations / plans / scheduled tasks / files panels
chat/messages.html # conversation_stage host, #messages, OpenSpace wrap, scroll nav, active agents panel
chat/task_tabs.html # #taskTabDock, #taskTabPanel
composer/controls.html # composer drawer grip, #promptControlsPanel, view menu, composer action mount
composer/input_row.html # unified attach/search/slash/@/STT/grab/#input/send shell
ext/hosts.html # #pf-ext-modal-host, CSS tooltip portal, #pf-ext-panel-host
boot/config.html # AGENT_PATH / API / SSE_URL / LOGIN_URL constants (tojson)
boot/scripts.html # asset-version guard, i18n block, extensions block, <script defer> loop
tasks/io/chat_ui/css/ # CSS modules at /chat/js/css/<file>?v=<per-file-content-hash>
00_base.css # reset, :root, app layout, sidebar, sharing
05_motion.css # shared motion tokens, disclosure containment, reduced-motion policy
10_chrome.css # collapsible grips, header status widgets
20_messages.css # gauges, messages, simplified live view, send button
30_mobile.css # narrow-viewport overrides
40_delegates.css # delegate blocks, cancel, ask_parent
50_composer.css # composer drawer, cognitive panel chrome
55_appearance.css # scoped appearance, atmosphere media and translucent surfaces
58_modern_ui.css # composer shortcuts, search, code headers and memory records
60_openspace.css # OpenSpace 3D view
70_grab.css # terminal grab mode
75_composer_shell.css # unified responsive prompt component and picker
80_dialogs.css # exec approval + generic dialogs
85_terminal_files.css # terminal output, file explorer
90_tabs.css # tab bar, tab panels, desktop/audio tabs
95_action_dock.css # action menu + conversation dock
96_desktop_dock.css # Active Desktops dock popover + stop confirmation
99_theme_bridge.css # --pf-* variable bridge (last)
CSS cascade: the modules are linked in _CSS_MODULES order (the NN_
prefix mirrors it; the list in serve_chat_ui.py is authoritative), then
<style id="custom-css"> (serveChatUI custom_css), then the highlight.js
theme, then <style id="custom-theme"> (the user's theme). Adding a module
means adding the file and its entry in _CSS_MODULES; the contract test
checks both. The old single inline <style> is gone: a CSS-only change now
ships one small cacheable file.
The WebChat uses native DOM, CSS transforms, and the Web Animations API; it does not ship a component framework or a parallel legacy path. The ordered controller modules load before their consumers:
ui_motion.jsowns reduced-motion state, read-before-write frame batching, replaceable animation channels, FLIP transitions, and optional diagnostics;ui_disclosure.jsowns interruptible disclosure state, live-size retargeting,aria-expanded/aria-hidden/hidden/inert, focus restoration, and teardown;ui_projection.jsobserves canonical transcript rows and patches only dirty keyed rows. Filtered and OpenSpace projections disconnect while hidden;ui_floating_layer.jsowns viewport placement, Escape/outside-interaction handling, focus behavior, listeners, and portal cleanup for tooltips, menus, popovers, and workflow dialogs;resources_patch.jspreserves the Resources root and keyed section identity, so opening a section is independent from refresh and refresh does not erase focus, scroll, or disclosure state.
Workflow lanes, cards, run rows, inspector regions, and action controls are
patched by stable keys. Action buttons reserve idle, pending, success, and error
faces inside one stable box; a local generation owns completion so a stale
request cannot regress the visible state. Simplified-turn rain uses one
visibility-aware timer plus shared observers, and stops while hidden or under
reduced motion. Sidebar grips, view switches, and progress indicators move with
transforms; no migrated CSS rule retains a layout-bound position transition or
a permanent will-change hint. The padded header is clipped by an animated
outer shell so it can travel continuously to zero, while the desktop right tab
rail and its content move as one 900 ms drawer. The separate rail token becomes
zero under reduced motion without changing the other 500 ms chrome expanders.
Diagnostics are inert unless tests set __PF_MOTION_DIAGNOSTICS__ or
__PF_FLOATING_DIAGNOSTICS__. They expose counts and timings only, never message
content or user/resource values.
The header Appearance button opens a user-owned preference panel. The selected
scale (75–150%), background source and atmosphere effects are independent from
themes. An authenticated user owns one global record inherited by every
conversation; a conversation can own an override.
core/appearance_store.py persists those small records per user, while
appearance uploads (image/video, maximum 80 MiB) use a private, non-expiring
FileStore category. The client loads its namespaced localStorage/IndexedDB cache
first for instant paint and offline use, then silently hydrates from the server.
It migrates pre-sync browser preferences and blobs once per authenticated user;
after that marker exists, the server remains authoritative so stale devices
cannot resurrect a cleared override. Superseded private uploads are deleted when
no appearance record references them. Remote backgrounds require HTTPS and
contact their host directly. Image/video motion is disabled when
prefers-reduced-motion is active, and videos pause while the page is hidden.
The surrounding chat surfaces remain theme-neutral:
- the prompt row exposes compact search, slash-command and agent-mention
shortcuts without changing message submission; below 768 px, only the
secondary-action toggle, prompt, selected agent, and Send stay visible, while
attach, search, slash, mention, Micro, Grab, and extension actions are stacked
in an accessible full-width panel above the composer. Its action rows override
the compact icon dimensions with equal selector specificity so their labels
remain inside the mobile viewport. Above that breakpoint, Micro and Grab
return to the trailing controls immediately before Send. The selected agent
remains visible in a
thin, localized
Selected agent: <name>button at the prompt row's right edge, immediately before Send; activating it opens a conversation-aware quick selector. On narrow screens the button truncates and the selector becomes a full-width touch-friendly panel that stays inside the viewport; - the thin conversation-controls strip puts Refresh first and exposes compact
permission, conversation-theme, conversation-appearance, and OpenSpace
controls. Its buttons share the action dock's dimensions, resting surface,
accent border, spring hover zoom, and shadow while respecting reduced-motion
preferences. Header dock buttons, header status buttons, conversation controls,
and action-dock items all use
--pf-sidebaras their resting background; the generic themedbuttonsurface must not create a different-colored subset. Global and conversation Appearance controls use the same palette glyph (U+1F3A8). Permission is a real button opening an accessible menu rather than a visible native combo; permission and theme controls show only their compact icons while closed but retain current-value tooltips and accessible labels; - activating OpenSpace always resets its camera to the general home view after the scene is ready; close-up and manually moved camera state is never restored when returning from Webchat;
- above 768 px, the right tab rail is independent from the Conversations/Resources sidebar: a persistent themed edge hint reveals the fixed overlay on pointer approach or keyboard focus, while the sidebar grip controls only the sidebar. Atmosphere mode never puts this rail back in the body flex, so it reserves no width in the header, transcript, or composer; narrow layouts retain the coupled overlay behavior;
Ctrl/Cmd+Kand/search <query>share the same overlay over the latest 500 conversation messages;- fenced Markdown code has a language header and accessible copy action;
- the memory browser uses tokenized record/card classes rather than a fixed dark inline palette.
All these rules use existing --pf-* variables. A custom theme therefore
continues to work unchanged; atmosphere overrides apply only while a personal
background is active.
Rules for partials:
- one region per file, ≤ 300 lines (target ≤ 150); a region that grows is split again, the skeleton never regains markup of its own;
- partials are plain HTML plus Jinja includes/expressions; they never define blocks or macros that another partial depends on;
- every element the JS modules address by id stays in exactly one partial
(
tests/test_chat_ui_templates.pypins the id / slot / i18n-key sets).
tests/test_webchat_motion.py pins module ordering and the source-level cost
boundaries. The focused Node suites under tests/js/ cover interruption,
generation ownership, keyed reconciliation, accessibility, and teardown.
tests/test_webchat_motion_browser.py uses the rendered production shell and
real controller modules in Chromium: 500/1,000-row streaming projections,
30-interaction disclosure matrices at desktop/mobile widths with normal/reduced
motion, computed geometry and screenshots, a CDP trace, and a 100-cycle lifecycle
soak. Functional, geometry, accessibility, clone-count, and lifecycle assertions
run wherever Chromium is installed. Timing and Long Task budgets are release
gates only when PAWFLOW_REFERENCE_BROWSER=1 selects the declared reference
browser environment.
An installed ui_extension may declare assets.templates: [{slot, path}] — inert HTML fragments the server renders into the page
before JS boot (PFP_DEVELOPER_GUIDE.md, "Server-rendered template
fragments"). Slots: the ten DOM slots (action_menu, gear_menu,
resources_panel, sidebar_top, sidebar_bottom, header_actions,
tab_bar, conversation_stage, resources_collection,
composer_accessory) plus head and body_end.
serve_chat_ui._enabled_ui_extension_records()is the single gate (user, install records, kill switch,ui.v1, per-conversation toggle) for both the boot manifest and the fragments;_template_fragments()reads each fragment once per(package, sha256), checks containment, size (64 KiB) and digest against the signed install record, and wraps it in<div data-pf-ext="<package>" data-pf-template-slot="<slot>">(head: comment markers). A bad fragment is skipped and logged once.- Templates call two environment globals:
{{ ext_fragments('slot')|safe }}inside every slot host and at thehead/body_endpoints of the skeleton, and{{ ext_hidden('slot') }}on the conditional hosts (conversation_stage,resources_collection,composer_accessory), which drops thehiddenattribute when a fragment is present. The fragment text is never compiled by Jinja:|safeis the only thing that happens to it. ext_runtime.jsre-renders only its own[data-pf-slot-entry]children, keeps a host visible while it holds a[data-pf-template-slot]node, and removes a package's fragments onunregister().- Adding a slot: add the host (or point) in the partial with the two
globals, the name in
core/pfp_package/_pp_base.py(_UI_TEMPLATE_SLOTS, plus_UI_KNOWN_SLOTSandext_runtime.jsKNOWN_SLOTSfor a DOM slot), the snapshot fixture and the developer guide. Additive changes stayui.v1.
Tests never read a template file: tests/chat_ui_testing.py exposes
rendered_chat_html(**context) (the page rendered through
render_chat_page) and chat_ui_partial(name) (raw source of one partial
for region-specific invariants). Assert on the rendered page unless the
invariant is about a partial's own source.
- Add a partial: create the file under its region directory,
{% include "region/file.html" %}it from the skeleton (or from the region partial it belongs to), and move the markup verbatim. Nothing else to register — the asset signature globstemplates/**. - Add a CSS module: create
css/NN_name.cssand add its name to_CSS_MODULESinserve_chat_ui.pyat the right position; theNN_prefix must mirror that position. The contract test fails on a file that is not listed, or a listed name with no file. - Add a JS module: unchanged — append to
_JS_MODULESin load order. - Add an extension slot: see the last bullet of the section above.
- Never reintroduce a string-replace marker: everything the server injects is a named context key.
All built-in and extension dialogs use the shared theme bridge rather than a
private palette. A dialog surface must use one of .dialog, .exec-dialog,
.cog-dialog, or .pf-ext-modal-box; the legacy resource editor is also
covered through #resourceEditorOverlay. The bridge provides the themed
surface and overlay colors, fields, rounded Beautiful UI tables, keyboard
focus, and dialog-button motion. Buttons use the dock's spring transition at
a smaller scale suitable for text labels, while prefers-reduced-motion
removes that motion. New dialogs must keep positive and destructive semantics
through .btn-primary/.exec-approve and .btn-danger/.exec-deny.
The contract also covers dynamically-created direct body overlays/dialogs, so
their buttons cannot fall back to a module-private shape or interaction style.
Dynamic dialog modules use only --pf-* tokens (including semantic accent,
success, warning, and danger tokens); literal hex/RGB palettes are forbidden.
Long server transactions use showOperationProgress() from dialogs.js.
The modal exposes real phase labels with indeterminate progress (never a fake
percentage), blocks duplicate/destructive input while busy, and becomes an
explicit dismissible error state on failure. Skill-draft promotion and both
conversation-import phases are the reference integrations.
Copy the changed file(s) under /app/tasks/io/chat_ui/... with the same
relative path. Templates are re-read on the next request; JS/CSS modules get
a new ?v= because the signature changed; serve_chat_ui.py itself needs a
restart.