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 ? (
) : (
)}
+
@@ -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,
+ }}
+ />
+
+ >
+ );
+ }}
+ />
- {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 `