Improvements#2
Open
imdt-joaov wants to merge 36 commits into
Open
Conversation
- 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).
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reasoning toggle por conversa (006ad75)
PUT /conversations/:cid/reasoning; novo contract + code-pointer +reasoning.tsno backend.supports_reasoning).Migração Tailwind v3 → v4 + shadcn (4c73b58)
tailwind.config.ts+postcss.config.jspor config inline novite.config.ts.ModelPicker.tsxremovido (virou submenu dentro doComposerOptionsMenu).Sidebar de conversas, 3 iterações (de d1344ff até 644518b)
ConversationList.tsxà mão consumindoGET/POST/DELETE /conversations, refetch acoplado a new/delete/first-message, estadoboot.cidno App.ConversationListpras primitivasSidebarMenuda shadcn (a0cca9f/7d859f3/f7675bc).useRemoteThreadListRuntimeda assistant-ui.ConversationList.tsxh-fullno root.Spec + tooling
ui-conversation-*reapontados pros novos símbolos; contratos comsection:atualizado..agents/skills/+.mcp.json+skills-lock.jsonadicionam as skills do assistant-ui e o MCP de docs ao harness (ec24b17).