diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..4f6dfae --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,23 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "Web Frontend", + "runtimeExecutable": "npm", + "runtimeArgs": ["--prefix", "apps/web", "run", "dev"], + "port": 5173 + }, + { + "name": "Backend API", + "runtimeExecutable": "bash", + "runtimeArgs": ["-c", "cd apps/backend && make all && make run"], + "port": 8007 + }, + { + "name": "Backend API (live reload)", + "runtimeExecutable": "bash", + "runtimeArgs": ["-c", "cd apps/backend && air"], + "port": 8007 + } + ] +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..4dc363f --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(go version *)", + "Bash(npm --version)", + "Bash(docker --version)", + "Bash(ollama --version)" + ] + } +} diff --git a/apps/backend/servers/echo/handlers/http.go b/apps/backend/servers/echo/handlers/http.go index 3f6986a..8d73e19 100644 --- a/apps/backend/servers/echo/handlers/http.go +++ b/apps/backend/servers/echo/handlers/http.go @@ -117,6 +117,7 @@ func Error(c echo.Context, logger *slog.Logger, err error) error { return echo.NewHTTPError(status, ErrorResponse{Error: message}) } + func logInternalError(c echo.Context, logger *slog.Logger, err error) { if logger == nil { logger = slog.Default() diff --git a/apps/backend/services/chat/internal/chat/llm/generate_sql_helpers.go b/apps/backend/services/chat/internal/chat/llm/generate_sql_helpers.go index a53b4dd..7d92edd 100644 --- a/apps/backend/services/chat/internal/chat/llm/generate_sql_helpers.go +++ b/apps/backend/services/chat/internal/chat/llm/generate_sql_helpers.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + chaterrors "github.com/Uncensored-Developer/datalk/apps/backend/services/chat/pkg/errors" llmtypes "github.com/Uncensored-Developer/datalk/apps/backend/services/chat/pkg/llm" schematypes "github.com/Uncensored-Developer/datalk/apps/backend/services/schemas/pkg/schemas" "github.com/mdobak/go-xerrors" @@ -114,7 +115,11 @@ func ParseGenerateSQLResponse(rawRequest, rawResponse []byte, payloadText string payload.SQL = strings.TrimSpace(payload.SQL) payload.Explanation = strings.TrimSpace(payload.Explanation) if payload.SQL == "" { - return nil, xerrors.New("structured SQL payload did not include sql") + explanation := strings.TrimSpace(payload.Explanation) + if explanation != "" { + return nil, xerrors.Newf("%s: %w", explanation, chaterrors.ErrInvalidSQL) + } + return nil, xerrors.Newf("the model could not generate SQL for this question: %w", chaterrors.ErrInvalidSQL) } return &llmtypes.GenerateSQLResponse{ diff --git a/apps/backend/services/chat/internal/chat/llm/openai/client.go b/apps/backend/services/chat/internal/chat/llm/openai/client.go index a0e3a92..1cbb914 100644 --- a/apps/backend/services/chat/internal/chat/llm/openai/client.go +++ b/apps/backend/services/chat/internal/chat/llm/openai/client.go @@ -338,11 +338,18 @@ func openAIInputMessages(req llmtypes.GenerateSQLRequest) []inputMessage { }) for _, message := range promptMessages { + role := normalizeOpenAIRole(message.Role) + // OpenAI Responses API requires "output_text" for assistant turns + // and "input_text" for user/developer turns. + contentType := "input_text" + if role == "assistant" { + contentType = "output_text" + } messages = append(messages, inputMessage{ - Role: normalizeOpenAIRole(message.Role), + Role: role, Content: []contentPart{ { - Type: "input_text", + Type: contentType, Text: message.Content, }, }, diff --git a/apps/backend/services/chat/internal/chat/llm/registry.go b/apps/backend/services/chat/internal/chat/llm/registry.go index 078cde3..fc3c4f7 100644 --- a/apps/backend/services/chat/internal/chat/llm/registry.go +++ b/apps/backend/services/chat/internal/chat/llm/registry.go @@ -246,17 +246,22 @@ func normalizeProviderModels(provider llmtypes.Provider, models []llmtypes.Model qualifiedModelID := QualifiedModelID(provider, rawModelID) if strings.Contains(rawModelID, ":") { + // Only treat the colon as a provider:model separator if the part + // before it is actually a known provider. Ollama model IDs use ":" + // as a tag separator (e.g. "llama3.2:1b"), which must not be + // mistaken for a qualified provider prefix. parsedProvider, parsedModelID, err := ParseQualifiedModelID(rawModelID) - if err != nil { - return nil, err + if err == nil { + // Providers are allowed to return already-qualified ids, but the + // embedded provider prefix still has to match the provider being queried. + if parsedProvider != provider { + return nil, xerrors.Newf("provider %s returned model id for different provider %s", provider, parsedProvider) + } + qualifiedModelID = QualifiedModelID(parsedProvider, parsedModelID) + rawModelID = parsedModelID } - // Providers are allowed to return already-qualified ids, but the - // embedded provider prefix still has to match the provider being queried. - if parsedProvider != provider { - return nil, xerrors.Newf("provider %s returned model id for different provider %s", provider, parsedProvider) - } - qualifiedModelID = QualifiedModelID(parsedProvider, parsedModelID) - rawModelID = parsedModelID + // If parsing failed the colon is part of the model's own id (e.g. an + // Ollama tag). Fall through and use the already-computed qualifiedModelID. } // Deduplicate after normalization so "gpt-5.2" and "openai:gpt-5.2" diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index fd97873..d18380c 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -242,7 +242,7 @@ export function AppShell({ title, children }: AppShellProps) { - + {children} diff --git a/apps/web/src/pages/chat/ChatPage.tsx b/apps/web/src/pages/chat/ChatPage.tsx index 9cb0163..0d36647 100644 --- a/apps/web/src/pages/chat/ChatPage.tsx +++ b/apps/web/src/pages/chat/ChatPage.tsx @@ -1,7 +1,6 @@ import CloseFullscreenOutlinedIcon from "@mui/icons-material/CloseFullscreenOutlined"; import CodeOutlinedIcon from "@mui/icons-material/CodeOutlined"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; -import KeyboardArrowRightOutlinedIcon from "@mui/icons-material/KeyboardArrowRightOutlined"; import OpenInFullOutlinedIcon from "@mui/icons-material/OpenInFullOutlined"; import PsychologyAltOutlinedIcon from "@mui/icons-material/PsychologyAltOutlined"; import SendOutlinedIcon from "@mui/icons-material/SendOutlined"; @@ -14,13 +13,10 @@ import Collapse from "@mui/material/Collapse"; import Dialog from "@mui/material/Dialog"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; -import Divider from "@mui/material/Divider"; -import FormControl from "@mui/material/FormControl"; import IconButton from "@mui/material/IconButton"; -import InputLabel from "@mui/material/InputLabel"; +import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import Paper from "@mui/material/Paper"; -import Select from "@mui/material/Select"; import Stack from "@mui/material/Stack"; import Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; @@ -32,7 +28,7 @@ import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { useParams } from "react-router-dom"; import { useAuth } from "../../auth/AuthProvider"; @@ -153,48 +149,28 @@ function MessagePanel({ onRetryModels: () => void; }) { const queryClient = useQueryClient(); - const messagesEndRef = useRef(null); + const scrollContainerRef = useRef(null); const streamTimersRef = useRef([]); const [streamedNaturalResponses, setStreamedNaturalResponses] = useState>({}); const lastMessageID = messages.at(-1)?.message.id; - - useEffect(() => { - if (lastMessageID && typeof messagesEndRef.current?.scrollIntoView === "function") { - messagesEndRef.current.scrollIntoView({ behavior: "smooth", block: "end" }); - } - }, [lastMessageID]); - - useEffect(() => { - const timers = streamTimersRef.current; - return () => { - for (const timer of timers) { - window.clearInterval(timer); - } - }; + const [isPending, setIsPending] = useState(false); + const [optimisticContent, setOptimisticContent] = useState(null); + + /** Always-instant, direct DOM scroll — most reliable across browsers. */ + const scrollToBottom = useCallback(() => { + const el = scrollContainerRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; }, []); - if (!conversation) { - return ( - - ); - } - - const streamNaturalResponse = (messageID: number, fullText: string) => { + const streamNaturalResponse = useCallback((messageID: number, fullText: string) => { const chunks = fullText.match(/\S+\s*/g) ?? [fullText]; let index = 0; setStreamedNaturalResponses((current) => ({ ...current, [messageID]: "" })); - const timer = window.setInterval(() => { index += 1; - const visibleText = chunks.slice(0, index).join(""); - setStreamedNaturalResponses((current) => ({ - ...current, - [messageID]: visibleText, - })); - + const visible = chunks.slice(0, index).join(""); + setStreamedNaturalResponses((current) => ({ ...current, [messageID]: visible })); if (index >= chunks.length) { window.clearInterval(timer); window.setTimeout(() => { @@ -207,23 +183,77 @@ function MessagePanel({ } }, 45); streamTimersRef.current.push(timer); - }; + }, []); + + // When real messages arrive: clear optimistic state, scroll to bottom. + useEffect(() => { + if (!lastMessageID) return; + setOptimisticContent(null); + setIsPending(false); + const id = setTimeout(scrollToBottom, 0); + + // Kick off typewriter for any new assistant message with a natural response. + const lastMsg = messages.at(-1); + if (lastMsg?.message.role === "assistant" && lastMsg.message.natural_response) { + streamNaturalResponse(lastMsg.message.id, lastMsg.message.natural_response); + } + + return () => clearTimeout(id); + }, [lastMessageID, scrollToBottom, streamNaturalResponse]); // eslint-disable-line react-hooks/exhaustive-deps + + // Scroll instantly the moment the user's optimistic bubble appears. + useEffect(() => { + if (optimisticContent) scrollToBottom(); + }, [optimisticContent, scrollToBottom]); + + // Cleanup timers on unmount + useEffect(() => { + const timers = streamTimersRef.current; + return () => { for (const t of timers) window.clearInterval(t); }; + }, []); + + const handleOptimisticMessage = useCallback((content: string) => { + setOptimisticContent(content); + setIsPending(true); + }, []); + + // Merge real messages with the optimistic user message + const allMessages: MessageListItem[] = useMemo(() => { + if (!optimisticContent || !conversation) return messages; + return [ + ...messages, + { + message: { + id: -1, + conversation_id: conversation.id, + role: "user" as const, + content: optimisticContent, + status: "pending" as const, + created_at: new Date().toISOString(), + }, + }, + ]; + }, [messages, optimisticContent, conversation]); + + if (!conversation) { + return ( + + ); + } const handleSendSuccess = (response: SendMessageResponse) => { const naturalResponse = response.assistant_message.natural_response?.trim(); - if (!naturalResponse) { - return false; - } + if (!naturalResponse) return false; queryClient.setQueryData( ["chat-messages", conversation.id], (current = []) => { const nextItems: MessageListItem[] = [ { message: response.user_message, retrieval: response.retrieval }, - { - message: response.assistant_message, - execution: response.execution, - }, + { message: response.assistant_message, execution: response.execution }, ]; const nextIDs = new Set(nextItems.map((item) => item.message.id)); return [ @@ -243,15 +273,12 @@ function MessagePanel({ display: "flex", flexDirection: "column", minHeight: 0, + maxWidth: 800, + width: "100%", + mx: "auto", }} > - - {conversation.title} - - Connection {conversation.connection_id} - - - + {/* Models error banner */} {modelsError ? ( } - sx={{ mb: 2 }} + sx={{ mb: 1.5 }} > {modelsError} ) : null} + {/* Messages scroll area */} {isLoading ? : null} + {messagesError ? ( ) : null} + + {/* Conversational welcome — no card, no border */} {!isLoading && !messagesError && messages.length === 0 ? ( - + + + {conversation.title} + + + Ask anything about your data. I'll write the SQL and show you the results. + + ) : null} - - {messages.map((item) => ( - - ))} - - + + {/* Message list (real + optimistic) */} + {allMessages.length > 0 ? ( + + {allMessages.map((item) => ( + + ))} + + ) : null} + + {/* Typing indicator — shown while the API is working */} + {isPending ? ( + + + {[0, 1, 2].map((i) => ( + + ))} + + + ) : null} + - + {/* Compose bar — no hard border separator */} + @@ -334,6 +412,28 @@ function MessagePanel({ ); } +function formatTimestamp(iso: string): string { + const date = new Date(iso); + const now = new Date(); + const isToday = + date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate(); + + const time = date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); + if (isToday) return time; + + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + const isYesterday = + date.getFullYear() === yesterday.getFullYear() && + date.getMonth() === yesterday.getMonth() && + date.getDate() === yesterday.getDate(); + + if (isYesterday) return `Yesterday ${time}`; + return `${date.toLocaleDateString([], { month: "short", day: "numeric" })} ${time}`; +} + function MessageItem({ item, streamedNaturalResponse, @@ -341,111 +441,66 @@ function MessageItem({ item: MessageListItem; streamedNaturalResponse?: string; }) { - const [detailsOpen, setDetailsOpen] = useState(false); const isAssistant = item.message.role === "assistant"; - const hasModelInfo = Boolean(item.message.provider || item.message.model); - const hasNaturalResponse = isAssistant && Boolean(item.message.natural_response); - const hasHiddenDetails = hasNaturalResponse && Boolean(item.message.content || item.execution); - const messageText = hasNaturalResponse - ? streamedNaturalResponse ?? item.message.natural_response + const timestamp = item.message.created_at ? formatTimestamp(item.message.created_at) : null; + + // Prefer natural_response (with optional typewriter effect) over raw content + const displayText = isAssistant && item.message.natural_response + ? (streamedNaturalResponse ?? item.message.natural_response) : item.message.content; return ( - - - theme.transitions.create("opacity", { - duration: theme.transitions.duration.shortest, - }), - }, - "&:hover .assistant-message-controls, &:focus-within .assistant-message-controls": { - opacity: 1, - }, - }} + {/* Bubble */} + + theme.palette.mode === "dark" ? "#374151" : "#dde4f0", + color: (theme) => + theme.palette.mode === "dark" ? "#f9fafb" : "#111827", + borderRadius: "12px 12px 3px 12px", + px: 2, + py: 1.25, + } + } > - - {isAssistant && (hasModelInfo || hasHiddenDetails) ? ( - - {hasModelInfo ? ( - - - - - - - - ) : null} - {hasHiddenDetails ? ( - - setDetailsOpen((open) => !open)} - size="small" - > - - theme.transitions.create("transform", { - duration: theme.transitions.duration.shortest, - }), - }} - /> - - - ) : null} - - ) : null} - {messageText} - {item.message.error_message ? ( - {item.message.error_message} - ) : null} - {hasNaturalResponse ? ( - - - - {item.message.content ? ( - - {item.message.content} - - ) : null} - {item.execution ? : null} - - - ) : item.execution ? ( - - ) : null} - - - + + {displayText} + + {item.message.error_message ? ( + {item.message.error_message} + ) : null} + {item.execution ? : null} + + + {/* Permanent timestamp */} + {timestamp ? ( + + {timestamp} + + ) : null} + ); } @@ -462,67 +517,72 @@ function ExecutionPanel({ execution }: { execution: MessageExecution }) { ].join(" | "); return ( - + - - - - - - - - - - setSqlOpen((open) => !open)} - size="small" - > - - - - - setFullscreenOpen(true)} - > - - - - - - - {execution.result.truncated ? ( - - ) : null} + {/* Toolbar */} + + + + + + + + setSqlOpen((open) => !open)} + size="small" + > + + + + + setFullscreenOpen(true)} + > + + + + + {execution.result.truncated ? ( + + ) : null} + {execution.generated_sql} + {isScalarResult ? ( ) : ( )} + setFullscreenOpen(false)}> @@ -555,12 +615,18 @@ function ScalarResult({ execution }: { execution: MessageExecution }) { py: 1.75, bgcolor: "action.hover", borderStyle: "dashed", + borderRadius: 2, }} > {column.name} - + {formatCellValue(value)} @@ -577,18 +643,20 @@ function ResultTable({ execution }: { execution: MessageExecution }) { } return ( - - + +
{execution.result.columns.map((column) => ( - {column.name} + + {column.name} + ))} {execution.result.rows.map((row, index) => ( - + {execution.result.columns.map((column) => ( {formatCellValue(row[column.name])} @@ -605,49 +673,51 @@ function ResultTable({ execution }: { execution: MessageExecution }) { function SendMessageForm({ conversationID, models, + onOptimisticMessage, onSendSuccess, }: { conversationID: number; models: ChatModel[]; + onOptimisticMessage: (content: string) => void; onSendSuccess: (response: SendMessageResponse) => boolean; }) { const { apiClient } = useAuth(); const queryClient = useQueryClient(); + const [modelMenuAnchor, setModelMenuAnchor] = useState(null); + const [sendError, setSendError] = useState(null); + const [shaking, setShaking] = useState(false); + const [requireNaturalResponse, setRequireNaturalResponse] = useState(() => { + if (typeof window === "undefined") return true; + const stored = window.localStorage.getItem(requireNaturalResponseKey); + return stored !== "false"; + }); + + const shake = useCallback(() => { + setShaking(true); + setTimeout(() => setShaking(false), 400); + }, []); + const defaultModel = useMemo(() => { const storedModel = typeof window === "undefined" ? null : window.localStorage.getItem(lastChatModelKey); - if (storedModel && models.some((model) => model.id === storedModel)) { return storedModel; } - return models[0]?.id ?? ""; }, [models]); - const [requireNaturalResponse, setRequireNaturalResponse] = useState(() => { - if (typeof window === "undefined") { - return true; - } - const stored = window.localStorage.getItem(requireNaturalResponseKey); - if (stored === "false") { - return false; - } - if (stored === "true") { - return true; - } - return true; - }); + const { control, formState: { errors }, handleSubmit, register, reset, - setError, } = useForm({ values: { content: "", model: defaultModel }, }); + const contentField = register("content", { validate: (value) => value.trim() ? true : "Message is required", }); @@ -673,134 +743,212 @@ function SendMessageForm({ onSuccess(response, values) { window.localStorage.setItem(lastChatModelKey, values.model); window.localStorage.setItem(requireNaturalResponseKey, String(requireNaturalResponse)); - reset({ content: "", model: values.model }); - const responseHandled = onSendSuccess(response); - if (!responseHandled) { + const handled = onSendSuccess(response); + if (!handled) { void queryClient.invalidateQueries({ queryKey: ["chat-messages", conversationID] }); } void queryClient.invalidateQueries({ queryKey: ["chat-conversations"] }); void queryClient.invalidateQueries({ queryKey: ["chat-conversation", conversationID] }); }, onError(error) { - setError("content", { message: errorMessage(error) }); + setSendError(errorMessage(error)); + shake(); }, }); + const isPending = mutation.isPending; + return ( - mutation.mutate(values))} - variant="outlined" - sx={{ - p: 1, - borderRadius: 3, - bgcolor: "background.paper", - boxShadow: (theme) => theme.shadows[1], - }} - > - + {/* Input row: text field + send button only */} + { + const content = values.content.trim(); + if (!content) return; + setSendError(null); + onOptimisticMessage(content); + reset({ content: "", model: values.model }); + mutation.mutate(values); + }, () => shake())} + elevation={2} + sx={{ + borderRadius: 1.5, + bgcolor: "background.paper", + border: "1px solid", + borderColor: (errors.content || sendError) ? "error.main" : "divider", + overflow: "hidden", + display: "flex", + alignItems: "flex-end", + gap: 0, + transition: "border-color 0.15s, box-shadow 0.15s", + "&:focus-within": { + borderColor: (errors.content || sendError) ? "error.main" : "primary.main", + boxShadow: (theme) => + `0 0 0 2px ${(errors.content || sendError) ? theme.palette.error.main : theme.palette.primary.main}22`, }, + ...(shaking + ? { + animation: "shake 0.35s cubic-bezier(.36,.07,.19,.97) both", + "@keyframes shake": { + "0%, 100%": { transform: "translateX(0)" }, + "15%": { transform: "translateX(-6px)" }, + "30%": { transform: "translateX(5px)" }, + "45%": { transform: "translateX(-4px)" }, + "60%": { transform: "translateX(3px)" }, + "75%": { transform: "translateX(-2px)" }, + "90%": { transform: "translateX(1px)" }, + }, + } + : {}), }} - {...contentField} - onKeyDown={(event) => { - if (event.key === "Enter" && !event.shiftKey && !mutation.isPending) { - event.preventDefault(); - void handleSubmit((values) => mutation.mutate(values))(); - } - }} - /> - - ( - - Model - - - )} + > + { + if (event.key === "Enter" && !event.shiftKey && !isPending) { + event.preventDefault(); + void handleSubmit((values) => { + const content = values.content.trim(); + if (!content) return; + setSendError(null); + onOptimisticMessage(content); + reset({ content: "", model: values.model }); + mutation.mutate(values); + }, () => shake())(); + } + }} /> + + + + + + {isPending ? ( + + ) : ( + + )} + + + + + + + {/* Below-input row: keyboard hint (left) + model selector (right) */} + + + Enter to send · Shift+Enter for new line + + + + + {/* Natural response toggle */} { - const nextValue = !requireNaturalResponse; - setRequireNaturalResponse(nextValue); - window.localStorage.setItem(requireNaturalResponseKey, String(nextValue)); + const next = !requireNaturalResponse; + setRequireNaturalResponse(next); + window.localStorage.setItem(requireNaturalResponseKey, String(next)); }} - size="small" > - - - - - {mutation.isPending ? ( - - ) : ( - - )} - - - + + {/* Model selector — bottom right, outside the input */} + { + const selected = selectedModelByID.get(field.value); + return ( + <> + setModelMenuAnchor(e.currentTarget)} + disabled={models.length === 0} + sx={{ + borderRadius: 999, + fontSize: "0.72rem", + cursor: "pointer", + maxWidth: 200, + height: 24, + }} + /> + setModelMenuAnchor(null)} + anchorOrigin={{ vertical: "top", horizontal: "right" }} + transformOrigin={{ vertical: "bottom", horizontal: "right" }} + slotProps={{ + paper: { sx: { minWidth: 220, borderRadius: 2, mb: 0.5 } }, + }} + > + {models.map((model) => ( + { + field.onChange(model.id); + setModelMenuAnchor(null); + }} + sx={{ borderRadius: 1, mx: 0.5, my: 0.25 }} + > + + + {model.display_name} + + {model.description ? ( + + {model.description} + + ) : null} + + + ))} + + + ); + }} + /> - {errors.content?.message || errors.model?.message ? ( - - {errors.content?.message ?? errors.model?.message} - - ) : null} - + ); } diff --git a/docs/superpowers/plans/2026-06-02-chat-ui-redesign.md b/docs/superpowers/plans/2026-06-02-chat-ui-redesign.md new file mode 100644 index 0000000..89d19c7 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-chat-ui-redesign.md @@ -0,0 +1,602 @@ +# Chat UI Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Redesign the chat interface in `ChatPage.tsx` to feel as smooth and polished as Claude or ChatGPT — clean message bubbles, a modern floating compose bar with a non-intrusive model picker, a conversational empty state, and no unnecessary chrome. + +**Architecture:** All changes are confined to `apps/web/src/pages/chat/ChatPage.tsx` and one new helper component (`ChatWelcome`). No backend changes. No new dependencies — only MUI components already in the project. Each task is independently shippable. + +**Tech Stack:** React 19, MUI v7, react-hook-form, @tanstack/react-query, TypeScript + +--- + +## What's wrong right now (reference for every task) + +| Problem | Location in code | +|---|---| +| Big `h1` title + "Connection X" subtitle above messages | `MessagePanel` lines 182–187 | +| Empty state is a bordered `Paper` card with inbox icon | `MessagePanel` line 235–239, `EmptyState` component | +| Both user AND assistant bubbles use `Paper variant="outlined"` — hard borders everywhere | `MessageItem` line 273 | +| Model selector is a full `` dropdown with `` label crammed inside the text area, and shows the full internal model ID in the menu items. It needs to feel like a clean floating input — text area + a minimal model chip + send button. + +**Target:** A floating Paper with no visible outline border (shadow only), the model shown as a small unobtrusive `Chip` in the bottom-left (clicking opens a `Menu` instead of a heavy `Select`), and only `model.display_name` shown — no redundant ID. + +**Files:** +- Modify: `apps/web/src/pages/chat/ChatPage.tsx` — `SendMessageForm` (lines 475–638) + +- [ ] **Step 1: Add `Menu` and `MenuItem` (already imported) — add `useState` for menu anchor** + +At the top of `SendMessageForm`, add: +```tsx +const [modelMenuAnchor, setModelMenuAnchor] = useState(null); +``` + +- [ ] **Step 2: Rewrite the compose bar JSX** + +Replace everything from `return (` to the end of `SendMessageForm` with: + +```tsx + return ( + + mutation.mutate(values))} + elevation={3} + sx={{ + borderRadius: 3, + bgcolor: "background.paper", + overflow: "hidden", + border: "1px solid", + borderColor: "divider", + }} + > + {/* Text input */} + { + if (event.key === "Enter" && !event.shiftKey && !mutation.isPending) { + event.preventDefault(); + void handleSubmit((values) => mutation.mutate(values))(); + } + }} + /> + + {/* Bottom toolbar */} + + {/* Model chip */} + { + const selected = selectedModelByID.get(field.value); + return ( + <> + setModelMenuAnchor(e.currentTarget)} + disabled={models.length === 0} + sx={{ + borderRadius: 999, + fontSize: "0.75rem", + cursor: "pointer", + maxWidth: 200, + }} + /> + setModelMenuAnchor(null)} + anchorOrigin={{ vertical: "top", horizontal: "left" }} + transformOrigin={{ vertical: "bottom", horizontal: "left" }} + slotProps={{ paper: { sx: { minWidth: 220, borderRadius: 2 } } }} + > + {models.map((model) => ( + { + field.onChange(model.id); + setModelMenuAnchor(null); + }} + > + + + {model.display_name} + + {model.description ? ( + + {model.description} + + ) : null} + + + ))} + + + ); + }} + /> + + + + {/* Send button */} + + + + {mutation.isPending ? ( + + ) : ( + + )} + + + + + + {/* Inline error */} + {errors.content?.message || errors.model?.message ? ( + + {errors.content?.message ?? errors.model?.message} + + ) : null} + + + {/* Hint */} + + Enter to send · Shift+Enter for new line + + + ); +``` + +- [ ] **Step 3: Remove unused imports** + +Remove `Select`, `InputLabel`, `FormControl` from the imports at the top of the file (now unused). Keep `Menu` — add it to the imports if it's not already there. + +Add `Menu` to MUI imports: +```tsx +import Menu from "@mui/material/Menu"; +``` + +- [ ] **Step 4: Verify compose bar** + +Check: +- Clicking the chip opens a menu above it (not a dropdown in the box) +- Only `display_name` shown in both chip and menu (no internal ID) +- Send button is a small circle, tooltip says "Send (Enter)" / "Responding…" +- Shift+Enter adds a newline, Enter sends +- Error shows inline below the toolbar, not above + +- [ ] **Step 5: Commit** +```bash +git add apps/web/src/pages/chat/ChatPage.tsx +git commit -m "feat(chat): redesign compose bar — chip model picker, clean layout, keyboard hints" +``` + +--- + +## Task 5: Polish — spacing, separator, and scroll + +**Problem:** A few rough edges remain: the hard `borderTop` line between messages and the compose area feels rigid; the messages scroll area has asymmetric right-only padding; the overall vertical rhythm feels crowded. + +**Files:** +- Modify: `apps/web/src/pages/chat/ChatPage.tsx` — `MessagePanel` layout + +- [ ] **Step 1: Remove the hard border-top separator** + +Find the compose area wrapper Box: +```tsx + +``` + +Replace with: +```tsx + +``` + +The compose Paper's `elevation={3}` already creates a visual lift — no need for a hard line. + +- [ ] **Step 2: Fix messages scroll area padding** + +Change the messages scroll area from: +```tsx +sx={{ + flex: 1, + minHeight: 0, + overflowY: "auto", + pr: { xs: 0, sm: 1 }, + pb: 2, +}} +``` +To: +```tsx +sx={{ + flex: 1, + minHeight: 0, + overflowY: "auto", + pb: 2, +}} +``` +(Symmetric — no right-only padding. The message bubbles themselves have `px: 1` now from Task 2.) + +- [ ] **Step 3: Increase spacing between messages** + +Change `` to `` in the messages area for more breathing room between turns. + +- [ ] **Step 4: Widen the chat column slightly** + +The current `maxWidth: 740` is good for centering but a touch narrow for data tables. Bump to `maxWidth: 800`. + +- [ ] **Step 5: Verify overall layout** + +Check the full page — no harsh divider line, messages have good spacing, tables don't clip, compose area floats cleanly at the bottom. + +- [ ] **Step 6: Commit** +```bash +git add apps/web/src/pages/chat/ChatPage.tsx +git commit -m "feat(chat): polish spacing, remove hard separator, fix symmetric padding" +``` + +--- + +## Done — what changed + +| Before | After | +|---|---| +| Big `h1` page title + "Connection X" subtitle | Gone — welcome text shown inline in empty state only | +| Bordered card empty state with inbox icon | Centered conversational welcome text | +| Both message types use outlined Paper boxes | User = pill bubble · Assistant = plain text | +| Full `