RFD D16: Read-Only Web UI for Conversations
- Status: Draft
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-04-06
Summary
This RFD introduces a read-only web interface for browsing JP conversations from a mobile device. The server is started via jp serve --web and serves server-rendered HTML using Axum and Maud, with a mobile-first CSS layout that supports automatic dark mode. No JavaScript is required.
Motivation
JP conversations are currently only readable through the terminal via jp conversation print. This works well at a workstation but is inaccessible when away from the machine — most commonly when reading from a phone.
A lightweight web UI that renders conversations as HTML solves this. It needs to be mobile-first (the primary use case is reading on a phone at night), support dark mode, and preserve the native iOS/Android text interactions (zoom, select, copy-paste) by avoiding custom JavaScript controls.
Design
User Experience
The UI follows the standard chat-app layout:
- Conversation list (left pane / drawer): Shows all conversations sorted by last activity, displaying title and timestamp. On mobile this is the default view; tapping a conversation navigates to the detail view.
- Conversation detail (right pane / main view): Renders the full conversation history as a scrollable chat view. User messages and assistant responses are visually distinct. Tool calls are rendered as collapsible blocks. Markdown content is rendered to HTML with syntax-highlighted code blocks.
- Navigation: The sidebar slides in/out on mobile via a CSS-only toggle (no JavaScript). On wider viewports, the sidebar is always visible.
CLI Entry Point
A new serve subcommand is added to the CLI:
jp serve --web # default: 127.0.0.1:3141
jp serve --web --port 8080
jp serve --web --bind 0.0.0.0 # for phone access over LANThe command initializes the workspace using the standard startup pipeline, then starts an HTTP server that blocks until interrupted. The serve subcommand is a top-level command (not nested under conversation) because it will later host the --http-api endpoint as well.
Configuration
A new top-level server.web config section:
[server.web]
bind = "127.0.0.1" # listening address
port = 3141 # listening portThis is added to AppConfig via a new ServerConfig struct containing a nested WebServerConfig. The server namespace is reserved for future additions like server.http_api.
Routes
| Route | Description |
|---|---|
GET / | Redirect to /conversations |
GET /conversations | Conversation list, sorted by last_activated_at |
GET /conversations/:id | Render a single conversation's chat history |
GET /assets/style.css | Embedded CSS, served with cache headers |
All routes return server-rendered HTML. The :id parameter accepts the conversation's decisecond timestamp identifier (the same format used by jp conversation show).
Architecture
New Crate: jp_web
crates/jp_web/
├── Cargo.toml
└── src/
├── lib.rs # start_server(workspace, config) -> Result
├── state.rs # Shared application state (Arc<Workspace>, etc.)
├── routes.rs # Axum router and handler functions
├── views/
│ ├── layout.rs # Base HTML shell (maud)
│ ├── list.rs # Conversation list page
│ └── detail.rs # Conversation detail page
├── render.rs # Event rendering: ConversationStream → HTML
└── style.rs # Embedded CSS via include_str!()The crate depends on jp_workspace, jp_conversation, and jp_config for data access, and on axum, maud, comrak, and syntect for serving and rendering.
Technology Choices
Axum (0.8): HTTP framework built on tokio and tower. JP already depends on tokio with full features enabled, so axum reuses the existing async runtime with minimal additional dependency surface.
Maud (0.27): Compile-time HTML macro. Generates HTML from Rust expressions with automatic escaping. The transitive dependency cost is near-zero — maud pulls in itoa, proc-macro2, quote, and syn, all of which are already in the dependency tree. The only net-new crate is proc-macro2-diagnostics. Maud is preferred over minijinja for this use case because it keeps the HTML structure co-located with the Rust rendering logic, provides compile-time type checking, and avoids runtime template registration — advantages that outweigh the minor additional dependency for a UI with only 2-3 pages.
Comrak (existing): Already a workspace dependency. Used in HTML output mode (markdown_to_html()) to render conversation message content.
Syntect (existing): Already a workspace dependency. Used to produce <span>-based syntax highlighting for code blocks in HTML output (instead of the ANSI escape sequences used by the terminal renderer).
Rendering Pipeline
The web renderer mirrors the logic in jp_cli::cmd::conversation::print but targets HTML instead of ANSI:
- Load conversation events via
Workspace::events(handle). - Iterate turns via
ConversationStream::iter_turns(). - For each event:
ChatRequest→ render user message bubble (markdown → HTML via comrak).ChatResponse::Message→ render assistant message bubble.ChatResponse::Reasoning→ render as a collapsible<details>block (respectingstyle.reasoning.displayconfig).ChatResponse::Structured→ render as a<pre>JSON block.ToolCallRequest/ToolCallResponse→ render as a collapsible<details>block showing tool name, arguments, and result. Hidden tools (perconversation.tools.<name>.style.hidden) are skipped.InquiryRequest/InquiryResponse→ skipped (same as terminal print).
CSS Strategy
A single CSS file embedded via include_str!() and served at /assets/style.css with a content-hash ETag for cache busting.
Key properties:
- Dark mode:
@media (prefers-color-scheme: dark)— follows the OS setting automatically, no JavaScript toggle. - Mobile-first: Default layout is single-column. Sidebar appears as a drawer toggled via a CSS checkbox hack (
<input type="checkbox">+<label>+ sibling selectors). No JavaScript. - Responsive breakpoint: At wider viewports (e.g.
>768px), the sidebar is always visible alongside the conversation. - Native interactions: Standard viewport meta tag (
width=device-width, initial-scale=1). No JavaScript that could interfere with iOS text selection, zoom, or copy-paste. - Code blocks: Syntax highlighting via inline
styleattributes from syntect. Dark/light themes selected via CSS custom properties or theprefers-color-schememedia query.
Workspace Access
The Workspace struct is shared with the axum handlers via Arc. The web server opens the workspace in read-only mode — it never acquires conversation locks or writes to disk. Conversation metadata and events are loaded lazily on request (the existing OnceLock-based lazy loading in Workspace handles this).
If the workspace is also being used by a concurrent jp query session, the web server sees a consistent snapshot of whatever was last persisted to disk. There is no conflict because the web server only reads.
Drawbacks
- New binary surface area: The axum dependency adds HTTP server code to the JP binary. This increases binary size and widens the attack surface (an HTTP listener on the network).
- No live updates: The MVP serves static HTML on each page load. If a conversation is actively being written to by
jp query, the web view shows stale data until the page is refreshed. - Single workspace: The server serves conversations from a single workspace (the one where
jp serveis run). Accessing conversations from multiple workspaces requires running multiple servers.
Alternatives
Minijinja instead of Maud
Minijinja is already a workspace dependency, so using it would add zero new crates. However, maud's transitive dependency cost is near-zero (one net-new crate: proc-macro2-diagnostics), and it provides compile-time HTML type checking, automatic escaping, and co-location of markup with rendering logic. For a small UI with 2-3 pages, the ergonomic benefits justify the minimal dependency cost.
Askama instead of Maud
Askama uses file-based Jinja2 templates compiled at build time. It is better suited for projects with many templates or where non-Rust contributors edit HTML. For 2-3 pages authored by Rust developers, maud's inline approach is more ergonomic and avoids the overhead of maintaining separate template files.
Static site export instead of a server
An alternative is jp conversation export --html that writes static HTML files. This avoids running a server but requires re-exporting after each conversation update. A server provides always-current data with no manual step. A static export could be added later as a complementary feature.
Non-Goals
- Write support: This RFD covers read-only access. Sending messages or editing conversations through the web UI is future work.
- Authentication: The server binds to localhost by default. When bound to
0.0.0.0, it is accessible to anyone on the LAN. Adding authentication is deferred to a future RFD. - HTTP API: A JSON API (
jp serve --http-api) is planned but out of scope for this RFD. Theservesubcommand andserverconfig namespace are designed to accommodate it. - Real-time streaming: Live-updating conversations via SSE or WebSocket is future work. The MVP serves a static snapshot on each page load.
- Theming / custom CSS: The built-in CSS supports light and dark mode. User customization of colors or layout is not in scope.
Risks and Open Questions
- Concurrent workspace access: The web server reads from the same on-disk storage that
jp querywrites to. The current storage format uses append-only JSON files with file-level locking for writes. Read-only access without locks should be safe, but this needs validation — particularly around partially-written event files. - Binary size impact: Axum and its tower/hyper transitive dependencies will increase binary size. This should be measured after Phase 1 to decide whether the web server should be gated behind a cargo feature flag.
- Code highlighting theme parity: The terminal uses syntect with ANSI output. The web version uses syntect with HTML output. The themes may not map 1:1. The dark/light theme pairing needs testing.
Implementation Plan
Phase 1: Crate skeleton and server infrastructure
- Create
jp_webcrate with axum server setup. - Add
server.webconfig section tojp_config(bind,port). - Add
jp serve --websubcommand tojp_cli. - Serve a placeholder HTML page at
GET /to validate the pipeline end-to-end. - Can be reviewed and merged independently.
Phase 2: Conversation list
- Implement
GET /conversationsroute. - Load all conversation metadata via
Workspace::conversations(). - Render as a list with title, timestamp, and link to detail view.
- Maud layout with embedded CSS (mobile-first, dark mode).
Phase 3: Conversation detail
- Implement
GET /conversations/:idroute. - Render conversation events as a chat-style view.
- Markdown → HTML via comrak. Code highlighting via syntect.
- Tool calls as collapsible
<details>blocks. - Sidebar navigation back to list.
Phase 4: Polish
- Responsive sidebar (CSS-only drawer on mobile, persistent on desktop).
- Cache headers for CSS asset.
- Test on iOS Safari to verify zoom/select/copy behavior.
- Measure binary size impact and decide on feature-flag gating.