Skip to content

Improvements#2

Open
imdt-joaov wants to merge 36 commits into
TiagoJacobs:trace-conversationsfrom
imdt-joaov:improvements
Open

Improvements#2
imdt-joaov wants to merge 36 commits into
TiagoJacobs:trace-conversationsfrom
imdt-joaov:improvements

Conversation

@imdt-joaov
Copy link
Copy Markdown

  1. Reasoning toggle por conversa (006ad75)

    • Server expõe PUT /conversations/:cid/reasoning; novo contract + code-pointer + reasoning.ts no backend.
    • Composer ganha o switch "Reasoning" (renderizado só para modelos supports_reasoning).
  2. Migração Tailwind v3 → v4 + shadcn (4c73b58)

    • Substitui o tailwind.config.ts + postcss.config.js por config inline no vite.config.ts.
    • Instala primitivas shadcn (button, card, collapsible, dropdown-menu, input, separator, sheet, skeleton, switch, textarea, tooltip) com tokens Tagus.
    • Composer unificado, ModelPicker.tsx removido (virou submenu dentro do ComposerOptionsMenu).
  3. Sidebar de conversas, 3 iterações (de d1344ff até 644518b)

    • v1: ConversationList.tsx à mão consumindo GET/POST/DELETE /conversations, refetch acoplado a new/delete/first-message, estado boot.cid no App.
    • v2: porta o ConversationList pras primitivas SidebarMenu da shadcn (a0cca9f/7d859f3/f7675bc).
    • v3 (final): troca tudo por useRemoteThreadListRuntime da assistant-ui. ConversationList.tsx
    • Cosmético junto: emojis → Lucide (Wrench/Zap/Brain/etc.), scrollbar fino + h-full no root.
  4. Spec + tooling

    • 4 code-pointers ui-conversation-* reapontados pros novos símbolos; contratos com section: atualizado.
    • ADR-0009 e o code-pointer de delete com PENDING RECONCILIATION (Radix entrou indireto via shadcn; auto-recreate ao deletar a thread ativa migrou pro runtime e falta verificar end-to-end).
    • .agents/skills/ + .mcp.json + skills-lock.json adicionam as skills do assistant-ui e o MCP de docs ao harness (ec24b17).

- Tailwind v3 (PostCSS + config) -> v4 (@tailwindcss/vite, CSS-only @theme).
- shadcn/ui installed (radix-nova style, neutral baseColor, CSS variables).
- ModelPicker + ReasoningToggle merged into a single ComposerOptionsMenu
  dropdown (model picker + reasoning toggle in one menu).
- Tagus design tokens applied to index.css: primary #149FD9,
  secondary #edf5f8, semantic colors, Poppins; light + dark.
- Composer: ComposerPrimitive.Root carries the input chrome; the textarea
  is reduced to a bare element so input + controls read as one surface.
- Root and ui/ lockfiles migrated from npm to bun.
Lift jwtRef and authedFetch up to App so the upcoming sidebar can issue
authed requests at the same scope as the chat runtime. Replace the
one-shot boot state with newConversation / switchConversation /
deleteConversation actions and a new lib/authedFetch.ts factory shared
by both layers; ChatRoom now receives the jwtRef and authedFetch as
props instead of owning them. No UX change yet — the sidebar consumer
lands in a later step.
Stand-alone sidebar content: fetches GET /conversations on mount and
whenever refetchKey changes, renders the header (Conversas + "+ Nova"),
a separator, and one row per conversation with title fallback, a
relative-time stamp, and a hover-revealed delete control. The component
is not yet wired into App.tsx; that lands in a later step.
Introduce a refetchKey counter in App, bumped by newConversation,
deleteConversation, and the first-user-message transition inside
ChatRoom. The first-message trigger uses runtime.thread.subscribe with
a per-mount ref so it fires exactly once per conversation (0→≥1
messages.length transition) — the server-derived title appears in the
sidebar as soon as the round-trip lands. The consumer (the sidebar
itself) is still wired in the next step.
Wire the ConversationList into a fixed-width aside on the left, with
the chat column flexing to the right (min-w-0 so long code blocks
inside messages cannot blow out the layout). The demo banner now lives
in the chat column so the sidebar palette stays untainted by the
destructive tint.
Replace the placeholder text states with: three pulsing skeleton rows
during the initial fetch, a centered "Nenhuma conversa ainda." copy
when the list comes back empty, and an error row with a Reconectar
button that retriggers the fetch via an internal attempt counter.
Active row carries aria-current="page" for screen readers.
Per /code-changed (Case A — code and spec agree). The UI sidebar
landed in commits d1344ff..87a4d97 and is a new consumer of four
already-current contracts: GET /conversations, POST /conversations,
DELETE /conversations/:cid, and GET /conversations/:cid/messages.
Adds one code-pointer per consumer under spec/src/evidence/code-pointers/
and references them from each contract's evidence list. No status
promotions — the contracts were already current; no UI tests in this
change.

session-chat.md is intentionally left alone: its mentions of the
"bundled UI" are scoped to chat-turn rendering (model chip, reasoning
section, read-only banner), not the conversation list. The sidebar
belongs to the four contracts above.
Add sidebar block (collapsible="icon" mode + SidebarProvider/SidebarInset
plumbing) and its block dependencies (sheet, skeleton, use-mobile hook,
input). The sidebar component lands unused; consumers in App.tsx and
ConversationList.tsx switch over in the next two commits. Tooltip gained
a "use client" header to match the other primitives — no behavior change
in a Vite build.
Swap the custom <aside> + flex column wrapper for shadcn's
SidebarProvider + Sidebar (collapsible="icon") + SidebarInset. Adds a
thin header at the top of the inset hosting SidebarTrigger so users can
collapse the sidebar to its icon rail. The demo banner and ChatRoom keep
their relative order — banner pinned at the top by flex order, composer
pinned at the bottom by ChatRoom's internal flex column, no
position:sticky needed. ConversationList still renders the legacy markup
inside Sidebar; the next commit swaps it to SidebarMenu primitives.
Replace the hand-rolled <button>/<span role="button"> row layout with
SidebarHeader + SidebarContent + SidebarMenu + SidebarMenuItem +
SidebarMenuButton + SidebarMenuAction. Three concrete wins:

- The ✕ delete control is now a real <button> via SidebarMenuAction
  instead of <span role="button">, fixing the nested-interactive HTML
  violation we previously had to dance around.
- "+ Nova" lives in SidebarHeader and collapses to an icon-only button
  in icon-rail mode; row tooltips kick in automatically in that mode.
- Loading skeletons use the shadcn SidebarMenuSkeleton primitive.

Also patches sidebar.tsx so data-active is omitted when isActive is
false. Tailwind v4's data-active: modifier matches attribute presence,
not truthiness (shadcn-ui/ui#9134), so passing isActive={false} would
otherwise render every row in the active style.
After the sidebar refactor (a0cca9f..f7675bc), the four UI consumers
moved: ConversationList.tsx grew the shadcn primitives wrapper and
App.tsx now lives inside SidebarProvider/SidebarInset. Bump each
code-pointer's ref to f7675bc so the evidence still resolves. No
content change — the contracts these prove (GET/POST/DELETE
/conversations, GET /conversations/:cid/messages) are unaffected by
the layout reshuffle.
Replaces the bespoke sidebar (ConversationList.tsx + App-level
new/switch/delete callbacks) with assistant-ui's RemoteThreadList:

- install the assistant-ui shadcn registry + threadlist-sidebar and
  thread-list components (header trimmed to a content-only shell,
  Archive item removed)
- new createThreadListAdapter mapping the contract to /conversations
  REST (list, initialize, delete, fetch, generateTitle); rename /
  archive / unarchive stay as no-ops since the server has no
  matching endpoints
- new ThreadHistoryAdapter via withFormat(aiSDKV6FormatAdapter) so
  GET /conversations/:cid/messages hydrates each thread's chat
  runtime; append is a no-op (the /chat stream persists server-side)
- App.tsx rewritten around useRemoteThreadListRuntime; runtimeHook
  eagerly initialize()s the active thread so /chat body.id is
  populated before the user types
- URL /c/<cid> is mirrored from the active thread's remoteId via a
  small UrlSync effect; deep-links use initialThreadId
- Composer reads conversationId from useAuiState so ConnectorsMenu /
  ComposerOptionsMenu stay tied to the active thread
Swap 🛠 ⚡ ⚖ 🧠 🔧 🔗 📄 ▾ for Wrench / Zap / Scale / Brain /
Wrench / Link2 / FileText / ChevronDown in the composer menus,
tool-call blocks, and source chips. App-level swaps (Bot for the
ModelChip and ChevronLeft/Right for BranchPicker) already landed
with the thread-list refactor.
Adds a slim webkit/firefox scrollbar style and stretches html /
body / #root to 100% height so the SidebarProvider's h-full
sizing reaches the viewport instead of collapsing to content.
Rewrites the four ui-conversation-* code-pointers to point at the
new entry points (createThreadListAdapter / createHistoryAdapter /
ThreadListPrimitive triggers) and updates the contracts' section
labels to match. The delete pointer carries a PENDING
RECONCILIATION block: the old "if active, mint a fresh one" branch
is now owned by useRemoteThreadListRuntime and hasn't been
verified end-to-end.

ADR-0009 also gets a PENDING RECONCILIATION block: the post-impl
"No Radix / no wrappers" claim no longer holds — the assistant-ui
shadcn registry components wrap Sidebar / Button / Skeleton, which
are Radix-based. Proposed direction: amend the bullet or spin a
new ADR; left for human review.

@wip markers in the code-pointer refs are placeholders until this
branch merges; bump to the merge commit then.
AUGCHATD_JWT_SECRET is now sourced from env: required in prod (length >= 32,
no placeholders), optional in demo where an ephemeral random secret is
generated at boot with a console warning that flags the implication
(every restart invalidates open sessions). The IIFE module-level secret
in src/jwt.ts is replaced by initJwt() wired from src/index.ts.

The iframe postMessage handshake now learns the parent origin from a
?parent_origin= query string on its own src URL and uses that as the
strict comparison value for inbound augchatd:jwt and as the targetOrigin
for outbound augchatd:ready / augchatd:route. When the query param is
absent, the iframe degrades to document.referrer's origin and logs a
one-time console.warn (back-compat for embedders that pre-date this
contract). The demo wrapper auto-appends the query param so /demo/
exercises the strict path daily.

Spec contracts updated to close the "iframe-side origin discovery"
known gap: browser-postmessage now documents the ?parent_origin= +
degrade mechanism, and ui-handshake's pending entry is removed.
…leware

augchatd no longer aspires to terminate TLS in-process. Bun.serve's TLS
config cannot expose the peer client certificate to a request handler
today (oven-sh/bun#12822, oven-sh/bun#16254), so the mTLS handshake moves
to a reverse proxy that validates the cert and forwards two headers:

  X-Client-Cert-Verify:  SUCCESS
  X-Client-Cert-Subject: CN=alice,OU=eng,O=acme   (RFC 2253)

New modules:
  - src/mtls-trust.ts — requireMtlsTrust middleware: gates on Verify ==
    "SUCCESS" and parses the Subject DN into a lowercased attribute map.
  - src/identity.ts — requireIdentity middleware: maps O -> tenantId,
    CN -> userId, validates the same alphabet src/env.ts uses for
    filesystem-bound idents.

New env var TRUSTED_PROXY: the operator's explicit declaration that
augchatd is reachable only via the proxy (loopback, unix socket, or
private network). In prod without it, mTLS routes are not mounted and a
boot warning is emitted. The header trust is a declarative agreement —
the flag does not verify the call's network origin, only the operator's
intent.

Wiring of POST /sessions and DELETE /sessions/:id onto these middlewares
is deferred to PR C / PR D; this PR is the architectural prep plus the
sample nginx config, ADR-0012, and updates to components.md,
security.md, and .env.local.example.

Smoke-verified the middleware chain in isolation (Hono app.request
against the composed middleware): no headers -> 401, Verify=FAILED ->
401, missing subject -> 401, malformed subject -> 400, subject without
O/CN -> 400, subject with invalid alphabet -> 400, valid subject -> 200
with parsed identity.
mcp.ts and rag.ts no longer hold module-level state. Their client maps
(ConnectedMcp / ConnectedRag) and the rag hits-by-toolCallId map move
into SessionRecord, so credentials and intermediate results stay inside
the session boundary. Signatures change accordingly: the caller passes
the session's Map into init/dispatch functions instead of relying on a
hidden singleton.

SessionRecord gains SessionConnectorState (mcpClients, ragClients,
ragHitsByToolCall). bindDemoSession takes the boot-shared Maps by
reference (demo is single-tenant, single-user — connecting to MCP on
every /demo/sessions mint would tank the JWT-refresh path); bindSession
(new) allocates fresh Maps per session for the prod path. unregisterSession
is added in preparation for DELETE /sessions/:id (PR D).

New POST /sessions handler at src/routes/sessions.ts: validates body via
zod, runs the LLM-credential probe and S3-writability probe (same posture
as demo boot), allocates the SessionRecord via bindSession, then runs
initMcpConnectors / initRagConnectors against the session's own Maps,
finally mintJwt and return. server.ts mounts this route — plus the
JWT-bearer chat/conversation/model routes — only when
`mode=prod && trusted_proxy`. The body's user_id is authoritative; the
cert's CN is sanity-checked but the integrator is the source of truth
for which user this session is for. tenantId comes from the cert's O.

Smoke-tested in-process: no mTLS -> 401 mtls_required, missing fields ->
400 invalid_payload with per-field zod detail, bad LLM key -> 400
llm_credential_probe_failed. Demo boot unchanged (MCP/RAG still connect
once at boot; bindDemoSession shares the boot-initialized Maps with
every minted demo session).
Mounts the mTLS-gated DELETE handler that fulfills contract-session-delete.
SessionRecord gains an AbortController; chat.ts merges it with the
per-request signal via AbortSignal.any, so a forced delete interrupts
the in-flight LLM stream and its tool calls immediately. After the
abort, the handler:

  1. Yields a microtask so the chat handler's onFinish callback lands
     the partial assistant message into hot SQLite before serialization.
  2. flushAllForSession (new in flush-scheduler.ts) — synchronously
     drains the per-(tenant, user) flush states. Now returns a boolean:
     true iff every targeted conversation is cleanlyFlushed after the
     attempt.
  3. If allFlushed is false: 503 with `flush_failed`, session stays
     registered for retry (per contract-session-delete "5xx if the
     final flush to cold cannot be confirmed; the session is not
     released — the integrator may retry").
  4. closeMcpClients — calls Transport.close() on each per-session MCP
     transport. RAG has no socket lifecycle.
  5. unregisterSession + noteSessionEnd — may trigger hot eviction via
     the existing maybeEvict path.

Cross-tenant DELETE → 403 (cert's O must match session.tenant_id).
Unknown session id → 404 (matches the spec's at-most-once semantics:
first DELETE returns 204, second returns 404).

Spec sync: removed PENDING RECONCILIATION block on session-delete.md
(the route is now mounted with the contract's promised behavior),
added evidence pointers on both session-delete and http-delete-sessions,
linked them to adr-0012.

Smoke-tested in-process: missing mTLS -> 401, wrong tenant -> 403,
unknown id -> 404, valid delete -> 204 with session removed and abort
signal fired, repeat -> 404 (idempotent at the result level, not the
status-code level).
@imdt-joaov imdt-joaov marked this pull request as ready for review May 28, 2026 12:32
Documents the decision that the user-selected model is owned by the
backend (conversations.model_id_override in SQLite) and surfaced via
GET/PUT /conversations/:cid/model. The /chat body carries no
config.modelName.

Records why we don't adopt the upstream assistant-ui ModelSelector +
modelContext().register() path: it splits state between client and
server and breaks reload-persistence — we'd end up persisting
server-side anyway and shipping the choice in the body, giving two
sources of truth.

Also marks "configure MCP servers from the browser" as out-of-scope.
The upstream @assistant-ui/react-mcp + McpConfigDialog flow is
incompatible with augchatd's trust boundary (credentials never reach
the browser); ConnectorsMenu stays toggle-only, governed by
contract-connector-toggle and ADR-0010.
Drops react-markdown + rehype/remark/highlight.js/mermaid (and the
custom blocks/{Mermaid,Json,Csv,CodeBlockShell,ToolCall}Block.tsx) in
favor of @assistant-ui/react-streamdown and the streamdown plugin
ecosystem (@streamdown/{code,math,mermaid,cjk}). Streamdown handles
block-based streaming with remend (auto-closes incomplete markdown),
ships a streaming caret, and integrates copy/download controls.

Migrates the chat surface to newer assistant-ui primitives:
  - useMessage(...)            → useAuiState((s) => s.message.*)
  - MessagePrimitive.If        → AuiIf condition={...}
  - MessagePrimitive.Parts     → render-prop form
  - ThreadPrimitive.Messages   → render-prop form
  - MessagePrimitive.GroupedParts groups reasoning + tool-call parts
    into a single "Thinking…/Thought" collapsible chain-of-thought.

Adds, all via official assistant-ui primitives:
  - Slash commands (/clear, /model, /connectors, /help) via
    unstable_useSlashCommandAdapter + a new ComposerTriggerPopover.
    Each command dispatches a window CustomEvent that
    ComposerOptionsMenu / ConnectorsMenu / HelpDialog listen for.
  - Voice — WebSpeechDictationAdapter + WebSpeechSynthesisAdapter,
    with mic / stop-dictation / read-aloud / stop-speaking controls.
  - Selection toolbar "Quote" button via SelectionToolbarPrimitive
    and ComposerPrimitive.QuoteText / QuoteDismiss.
  - Composer "Stop" button while running and a scroll-to-bottom FAB.
  - Reasoning and ToolFallback shadcn components for chain-of-thought
    entries; useDocumentTheme hook feeds the mermaid theme at JS time
    (streamdown reads mermaid.config.theme synchronously).
Hides the long tail of older provider models behind a nested submenu.
The 3 most recently-published models render directly in the picker;
everything else lives under "More models".

Ranking uses upstream publication date, normalized to ISO 8601 UTC:
  - OpenAI's /v1/models returns `created` (unix seconds) — converted via
    new Date(created * 1000).toISOString().
  - Anthropic's /v1/models returns `created_at` (RFC 3339) — passed
    through unchanged.

Both providers now sort `created_at` descending server-side so the
contract response is intuitively newest-first; the UI re-sorts
defensively (ISO 8601 strings sort lexicographically with localeCompare)
and slices `[0:3]` / `[3:]`. The submenu is only rendered when the
"rest" is non-empty.

When the currently-selected model is in the long tail, the trigger
button label still reflects it (reads from `models.find(id=current)`)
and the checkmark appears next to it inside the "More models" submenu.

Spec contract `http-get-session-models.md` updated in the same commit:
  - example payload now includes `created_at`
  - new field-table row documents the source/normalization
  - ordering invariant ("newest first") is documented so future
    consumers can rely on it
  - header / DemoBanner / Composer: right-1.25 → right-2 (5px → 8px),
    matching the canonical Tailwind spacing scale (the prior 1.25 was
    flagged by the editor's class-normalization lint).
  - ChatView viewport: pt-20 → pt-24 to give the header + DemoBanner
    stack room without clipping the first message.
Selecting text in an assistant message and hitting "Quote" in the
selection toolbar attaches `metadata.custom.quote = { text, messageId }`
to the next user message. The previous code dropped that metadata at
two points:

  - In the chat handler, `convertToModelMessages(body.messages)` strips
    `metadata`, so the LLM never saw the quoted excerpt.
  - In `UserMessage`, only `text` / `image` parts were rendered, so the
    user couldn't even see what they had quoted in their own bubble.

Backend fix: a new `injectQuoteContext()` helper folds the quote text
into a leading markdown blockquote `text` part *only on the model-input
copy*. The persisted user message keeps its original `parts` +
`metadata.custom.quote`, so re-hydration after reload restores the quote
chip from metadata without double-folding into the model context on the
next turn. Idempotent — re-running the transform on already-folded
input is a no-op. Mirrors `@assistant-ui/react-ai-sdk`'s helper but
inlined to avoid pulling a React-shaped package into the backend.

UI fix: `MessagePrimitive.Quote` renders a left-bordered, italic,
line-clamped chip above the user message text — same visual language as
the pending-quote chip inside the composer.

Spec: `session-chat.md` documents the new pre-LLM transform step;
`ui-rendering.md` §User messages documents the rendered chip and the
metadata-vs-parts boundary so the round-trip is unambiguous.
Renames the composer submenu label from "Model" to "Advanced" and
inserts a separator between the top-3 models and the "More models"
nested submenu so the two groups read as distinct sections.
Adds react-i18next bootstrap that reads `?locale=<bcp47>` from the
iframe `src`, falls back to `en` for missing/unsupported values, and
sets `document.documentElement.lang`. Catalogs at `ui/src/locales/`
cover the UI chrome — 70 keys with identical sets in en/fr. Message
content from the model, connector names, model display names, and
backend errors are passed through untranslated (chrome-only scope).

Spec: new `contract-ui-i18n` documents the promise, observable
outcomes, and non-promises (no in-UI picker, no runtime switching,
no overrides for library-rendered chrome). `contract-ui-handshake`
gains a bullet noting `?locale=` is the sibling of `?parent_origin=`;
`contract-ui-rendering` gains a non-promise stating message content
is not translated.
Adds a docker-compose stack as the canonical prod deployment path:

- Multi-stage Dockerfile (Bun runtime + bundled UI build).
- docker/nginx/ — terminates browser TLS (443) and mTLS control plane
  (8443) per adr-0012-out-of-process-tls. Entrypoint refuses to boot
  without server.crt / server.key / clients-ca.crt mounted in.
- docker/cert-init/ — alpine + openssl service that generates a full
  self-signed bundle (CA, server cert with SAN for the domain,
  clients-CA, sample client cert) into docker/certs/. Idempotent per
  artifact; gated by profiles: ["tools"] so it never auto-starts.
- docker-compose.yml — augchatd has no published ports; depends_on
  nginx with condition: service_healthy so the app only boots after
  the proxy is up. Encodes the "proxy up only with cert, app up only
  with proxy" chain in one declarative line.
- ADR-0014 documents the decisions; ADR-0012 evidence and
  components.md index are updated to reference docker/nginx/ instead
  of the now-removed docs/deployment/nginx.conf.example.

Compose stack is prod-only (AUGCHATD_MODE=prod, TRUSTED_PROXY=true,
AUGCHATD_JWT_SECRET required). Image publication is deferred.
Adds scripts/deploy.sh that takes a domain and brings up a fresh VPS:
apt prereqs, Docker Engine + Compose plugin via the official repo,
systemctl enable docker, UFW (22/80/443/8443), .env with a generated
JWT secret (preserved across reruns to keep live sessions valid),
self-signed cert bundle via the cert-init service, and the stack.

Idempotent end-to-end — safe to re-run after a partial failure or to
roll the domain. Assumes the repo is already on the VPS (git clone or
scp); no remote orchestration. Hardening scope is intentionally minimal
(UFW only); fail2ban / sshd hardening are left to the operator.
Adds a letsencrypt-init compose service that, on demand, replaces only
docker/certs/server.{crt,key} with a real Let's Encrypt cert. The CA,
clients-CA, and sample client from cert-init stay untouched — LE does
not issue mTLS client trust roots, and the port-8443 leg keeps its
local CA.

- docker/letsencrypt-init/ — alpine + certbot; idempotent `issue
  <domain> <email>` (skips when current server.crt is already an
  LE cert for the domain with >30 days remaining) and `renew
  [--dry-run]`. Always targets LE PRODUCTION; no staging flag, by
  design (single code path, no risk of leaving a fake-LE cert in
  prod-looking paths).
- docker-compose.yml — new service under profiles: ["tools"] with
  ports: ["80:80"] (only published when run with --service-ports,
  so an accidental `up` cannot hold port 80) and a named volume
  augchatd_letsencrypt persisting the ACME account between runs.
- scripts/deploy.sh — new optional `--letsencrypt <email>` flag.
  When passed, issues/refreshes the cert after the stack is up,
  reloads nginx, and installs a systemd oneshot+timer that runs
  the renewal weekly with `Persistent=true`. Unit content is
  compared via cmp before writing to avoid spurious daemon-reloads.
- ADR-0015 documents the decision: HTTP-01 standalone over webroot
  (no extra port-80 server block in nginx, no shared bind-mount),
  PROD-only (operator's explicit ask), self-signed fallback from
  cert-init kept as a safety net if ACME fails.

nginx config is unchanged; it still listens only on 443 + 8443.
Consequence accepted: http://<domain> returns connection refused
(no HTTP→HTTPS redirect — clients use https:// by construction).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants