Skip to content

Proposal: Chat Mode

Status: Shipped — Chat is the live default mode at /chat · Last updated: 2026-07-20 (status audit) · Tracking: issue #1

As-built note (2026-07-20): shipped as proposed, then evolved by later proposals — chrome/UX by floating-glass-shell.md, transcript branching by message-forks.md + background-fork-runs.md, attachments by chat-attachments.md, the paid built-in provider by credit-balance-ux.md.

Summary

Chat becomes PlebChat's default mode: a simple, multi-turn agent chat (ChatGPT-shaped) living at /chat, listed first in the mode selector. The mode-card welcome home is retired; / is the front door (brochure logged out, redirect to Chat logged in). Chat reuses the mode-agnostic agent runtime ($lib/ai) and Search's proven thread patterns, with device-local per-npub persistence and ?thread= deep links.

This document covers Chat v1 (issue #1 phases 1–2). The logged-out brochure build-out, demo, unified settings panel, and SEO artifacts (phases 3–6) are in proposals/brochure-settings-seo.md.

Motivation

"PlebChat" should feel like a chat app with specialist modes, not four equal mini-apps behind a chooser. A plain conversational agent is also the natural on-ramp: no Firecrawl key, no project setup — configure an endpoint and talk.

Design

Shell & routing

  • AppMode gains chat, listed first. Amended 2026-07-13 (phase 3): Chat's route is /chat, not / — the root became the front door (whole-site brochure logged out, redirect to /chat logged in) so it stays a pure marketing/SEO surface. See proposals/brochure-settings-seo.md. Existing modes keep /read, /search, /synthesize, /debate.
  • Debate's icon moves to Scale; Chat takes MessagesSquare.
  • The mode store no longer persists to localStorage: deep links preserve the mode in the path, so the current mode is fully derivable from the URL (the root layout syncs it on navigation, counting / as Chat). "Restore last mode on /" is explicitly rejected.
  • The ModeSelector's home-page "Select a mode" special case is retired along with the mode-card welcome.
  • Logged-out /chat is Chat's capability brochure, with the read-only seeded demo behind ?demo.

Data model (DB v4)

The former ChatThread (Read mode's per-book chat) is renamed BookChatThread; Chat mode owns the clean name. New store chatThreads (id key, by-updated index):

interface ChatThread {
  id: string;      // `chat-<nanoid>` — the ?thread= deep-link token
  title: string;   // first message excerpt; renameable
  pinned?: boolean;
  messages: unknown[]; // verbatim pi AgentMessage transcript
  createdAt: number;
  updatedAt: number;
}

Single-record shape like SearchThread (no metadata/transcript split — the rail loads all threads anyway). Device-local, deliberately outside the sync schema, same stance as Search threads and Read chats. No per-thread agent settings in v1 (the workspace's AgentSettings shows how to add them later).

Agent

  • One AgentRunner per thread (lazy map, kept across switches so concurrent runs keep streaming), copied from searchThreads' pattern — including the "no $state writes inside runnerFor()" invariant (it runs inside a $derived).
  • Zero tools in v1: buildTools: () => []. Still a real agent loop, so tools can land later with no plumbing changes (the runner rebuilds model/prompt/tools every send()).
  • Fixed persona prompt in $lib/chat/prompt.ts; a user-editable system prompt waits for the unified settings panel.

/chat?thread=<id> mirrored with replaceState, gated on handledInitialUrl (the same two-effect pattern as ?book= and ?project= — the mount-time mirror otherwise erases the param before it's read). Unknown or stale ids fall through silently to the hero view. The read-effect gates on chatThreads.ready, so a deep link opened while logged out survives login for free.

UI ($lib/chat/)

  • ChatView: left thread rail + main pane. Hero (tagline + input) when no thread is active; header + message list + input once one is.
  • Thread rail: expanded by default (ChatGPT-shaped), searchable, pinned group above date groups, inline rename, pin/unpin, confirm-to-delete (SvelteReader pattern).
  • Messages render via the shared renderMarkdown (moved to $lib/ai/markdown.ts now that Chat is its third consumer) and $lib/ai/render helpers. No sources rail, no tool cards in v1.
  • Unconfigured-AI gate opens the unified settings surface — since 2026-07-14 the full-page /settings route (the dialog and settingsUi are gone; see settings-page-and-chat-session-settings.md). The input-bar gear is now session settings: per-thread model + thinking level.

Deferred (tracked in issue #1, not v1)

Personas, attachments, tools, model-generated titles, opt-in encrypted thread sync (NIP-44 self-encrypted addressable events — design together with the workspace event-kind decision), selective sharing via child/ephemeral keys, richer brochure + demos.

Testing

e2e/chat.test.ts, mocked SSE endpoint (chat.completion.chunk frames), settings seeded via localStorage['plebchat-settings']:

  • Logged-out / shows the pitch; logged-in unconfigured shows the settings gate.
  • Chat roundtrip: bubbles, markdown, thread title, URL gains ?thread=.
  • Deep link: reload on /?thread=<id> reopens the thread; stale id falls back to hero.
  • Rail housekeeping: rename, pin (ordering), confirm-delete.
  • Shell: Chat first in selector, Debate uses Scale, /search still works on refresh.

Progress

  • Proposal doc
  • Mode store: chat first, / route, Debate icon → Scale, drop localStorage persistence
  • DB v4: BookChatThread rename + chatThreads store
  • $lib/chat/ (prompt, session, threads store, ui store, components)
  • /chat route: brochure + demo (logged out) / ChatView + ?thread= (logged in); / front door
  • renderMarkdown moved to $lib/ai/markdown.ts
  • Playwright e2e (chat.test.ts + shell.test.ts rework)
  • Phases 3–6 of issue #1 — see proposals/brochure-settings-seo.md