feat(web): polish master chat with model selector, params config, and empty state (#519)
All checks were successful
ci/woodpecker/push/web Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #519.
This commit is contained in:
2026-02-26 03:17:23 +00:00
committed by jason.woltje
parent 417c6ab49c
commit 13aa52aa53
9 changed files with 1020 additions and 13 deletions

View File

@@ -1,7 +1,66 @@
"use client";
import type { KeyboardEvent, RefObject } from "react";
import { useCallback, useState, useEffect } from "react";
import { useCallback, useState, useEffect, useRef } from "react";
export const AVAILABLE_MODELS = [
{ id: "llama3.2", label: "Llama 3.2" },
{ id: "claude-3.5-sonnet", label: "Claude 3.5 Sonnet" },
{ id: "gpt-4o", label: "GPT-4o" },
{ id: "deepseek-r1", label: "DeepSeek R1" },
] as const;
export type ModelId = (typeof AVAILABLE_MODELS)[number]["id"];
const STORAGE_KEY_MODEL = "chat:selectedModel";
const STORAGE_KEY_TEMPERATURE = "chat:temperature";
const STORAGE_KEY_MAX_TOKENS = "chat:maxTokens";
export const DEFAULT_TEMPERATURE = 0.7;
export const DEFAULT_MAX_TOKENS = 4096;
export const DEFAULT_MODEL: ModelId = "llama3.2";
function loadStoredModel(): ModelId {
try {
const stored = localStorage.getItem(STORAGE_KEY_MODEL);
if (stored && AVAILABLE_MODELS.some((m) => m.id === stored)) {
return stored as ModelId;
}
} catch {
// localStorage not available
}
return DEFAULT_MODEL;
}
function loadStoredTemperature(): number {
try {
const stored = localStorage.getItem(STORAGE_KEY_TEMPERATURE);
if (stored !== null) {
const parsed = parseFloat(stored);
if (!isNaN(parsed) && parsed >= 0 && parsed <= 2) {
return parsed;
}
}
} catch {
// localStorage not available
}
return DEFAULT_TEMPERATURE;
}
function loadStoredMaxTokens(): number {
try {
const stored = localStorage.getItem(STORAGE_KEY_MAX_TOKENS);
if (stored !== null) {
const parsed = parseInt(stored, 10);
if (!isNaN(parsed) && parsed >= 100 && parsed <= 32000) {
return parsed;
}
}
} catch {
// localStorage not available
}
return DEFAULT_MAX_TOKENS;
}
interface ChatInputProps {
onSend: (message: string) => void;
@@ -9,6 +68,11 @@ interface ChatInputProps {
inputRef?: RefObject<HTMLTextAreaElement | null>;
isStreaming?: boolean;
onStopStreaming?: () => void;
onModelChange?: (model: ModelId) => void;
onTemperatureChange?: (temperature: number) => void;
onMaxTokensChange?: (maxTokens: number) => void;
onSuggestionFill?: (text: string) => void;
externalValue?: string;
}
export function ChatInput({
@@ -17,9 +81,52 @@ export function ChatInput({
inputRef,
isStreaming = false,
onStopStreaming,
onModelChange,
onTemperatureChange,
onMaxTokensChange,
externalValue,
}: ChatInputProps): React.JSX.Element {
const [message, setMessage] = useState("");
const [version, setVersion] = useState<string | null>(null);
const [selectedModel, setSelectedModel] = useState<ModelId>(DEFAULT_MODEL);
const [temperature, setTemperature] = useState<number>(DEFAULT_TEMPERATURE);
const [maxTokens, setMaxTokens] = useState<number>(DEFAULT_MAX_TOKENS);
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
const [isParamsOpen, setIsParamsOpen] = useState(false);
const modelDropdownRef = useRef<HTMLDivElement>(null);
const paramsDropdownRef = useRef<HTMLDivElement>(null);
// Stable refs for callbacks so the mount effect stays dependency-free
const onModelChangeRef = useRef(onModelChange);
onModelChangeRef.current = onModelChange;
const onTemperatureChangeRef = useRef(onTemperatureChange);
onTemperatureChangeRef.current = onTemperatureChange;
const onMaxTokensChangeRef = useRef(onMaxTokensChange);
onMaxTokensChangeRef.current = onMaxTokensChange;
// Load persisted values from localStorage on mount only
useEffect(() => {
const storedModel = loadStoredModel();
const storedTemperature = loadStoredTemperature();
const storedMaxTokens = loadStoredMaxTokens();
setSelectedModel(storedModel);
setTemperature(storedTemperature);
setMaxTokens(storedMaxTokens);
// Notify parent of initial values via refs to avoid stale closure
onModelChangeRef.current?.(storedModel);
onTemperatureChangeRef.current?.(storedTemperature);
onMaxTokensChangeRef.current?.(storedMaxTokens);
}, []);
// Sync external value (e.g. from suggestion clicks)
useEffect(() => {
if (externalValue !== undefined) {
setMessage(externalValue);
}
}, [externalValue]);
useEffect(() => {
interface VersionData {
@@ -40,6 +147,22 @@ export function ChatInput({
});
}, []);
// Close dropdowns on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent): void => {
if (modelDropdownRef.current && !modelDropdownRef.current.contains(e.target as Node)) {
setIsModelDropdownOpen(false);
}
if (paramsDropdownRef.current && !paramsDropdownRef.current.contains(e.target as Node)) {
setIsParamsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return (): void => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const handleSubmit = useCallback(() => {
if (message.trim() && !disabled && !isStreaming) {
onSend(message);
@@ -65,6 +188,49 @@ export function ChatInput({
[handleSubmit]
);
const handleModelSelect = useCallback(
(model: ModelId): void => {
setSelectedModel(model);
try {
localStorage.setItem(STORAGE_KEY_MODEL, model);
} catch {
// ignore
}
onModelChange?.(model);
setIsModelDropdownOpen(false);
},
[onModelChange]
);
const handleTemperatureChange = useCallback(
(value: number): void => {
setTemperature(value);
try {
localStorage.setItem(STORAGE_KEY_TEMPERATURE, value.toString());
} catch {
// ignore
}
onTemperatureChange?.(value);
},
[onTemperatureChange]
);
const handleMaxTokensChange = useCallback(
(value: number): void => {
setMaxTokens(value);
try {
localStorage.setItem(STORAGE_KEY_MAX_TOKENS, value.toString());
} catch {
// ignore
}
onMaxTokensChange?.(value);
},
[onMaxTokensChange]
);
const selectedModelLabel =
AVAILABLE_MODELS.find((m) => m.id === selectedModel)?.label ?? selectedModel;
const characterCount = message.length;
const maxCharacters = 4000;
const isNearLimit = characterCount > maxCharacters * 0.9;
@@ -72,7 +238,230 @@ export function ChatInput({
const isInputDisabled = disabled ?? false;
return (
<div className="space-y-3">
<div className="space-y-2">
{/* Model Selector + Params Row */}
<div className="flex items-center gap-2">
{/* Model Selector */}
<div className="relative" ref={modelDropdownRef}>
<button
type="button"
onClick={() => {
setIsModelDropdownOpen((prev) => !prev);
setIsParamsOpen(false);
}}
className="flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-black/5 focus:outline-none focus:ring-2"
style={{
borderColor: "rgb(var(--border-default))",
backgroundColor: "rgb(var(--surface-1))",
color: "rgb(var(--text-secondary))",
}}
aria-label={`Model: ${selectedModelLabel}. Click to change`}
aria-expanded={isModelDropdownOpen}
aria-haspopup="listbox"
title="Select AI model"
>
<svg
className="h-3 w-3 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<circle cx="12" cy="12" r="3" />
<path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83" />
</svg>
<span>{selectedModelLabel}</span>
<svg
className={`h-3 w-3 transition-transform ${isModelDropdownOpen ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{/* Model Dropdown */}
{isModelDropdownOpen && (
<div
className="absolute bottom-full left-0 mb-1 z-50 min-w-[160px] rounded-lg border shadow-lg"
style={{
backgroundColor: "rgb(var(--surface-0))",
borderColor: "rgb(var(--border-default))",
}}
role="listbox"
aria-label="Available models"
>
{AVAILABLE_MODELS.map((model) => (
<button
key={model.id}
role="option"
aria-selected={model.id === selectedModel}
onClick={() => {
handleModelSelect(model.id);
}}
className="w-full px-3 py-2 text-left text-xs transition-colors first:rounded-t-lg last:rounded-b-lg hover:bg-black/5"
style={{
color:
model.id === selectedModel
? "rgb(var(--accent-primary))"
: "rgb(var(--text-primary))",
fontWeight: model.id === selectedModel ? 600 : 400,
}}
>
{model.label}
{model.id === selectedModel && (
<svg
className="inline-block ml-1.5 h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
aria-hidden="true"
>
<path d="M5 13l4 4L19 7" />
</svg>
)}
</button>
))}
</div>
)}
</div>
{/* Settings / Params Icon */}
<div className="relative" ref={paramsDropdownRef}>
<button
type="button"
onClick={() => {
setIsParamsOpen((prev) => !prev);
setIsModelDropdownOpen(false);
}}
className="flex items-center justify-center rounded-full border p-1 transition-colors hover:bg-black/5 focus:outline-none focus:ring-2"
style={{
borderColor: "rgb(var(--border-default))",
backgroundColor: isParamsOpen ? "rgb(var(--surface-2))" : "rgb(var(--surface-1))",
color: "rgb(var(--text-muted))",
}}
aria-label="Chat parameters"
aria-expanded={isParamsOpen}
aria-haspopup="dialog"
title="Configure temperature and max tokens"
>
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
</button>
{/* Params Popover */}
{isParamsOpen && (
<div
className="absolute bottom-full left-0 mb-1 z-50 w-64 rounded-lg border p-4 shadow-lg"
style={{
backgroundColor: "rgb(var(--surface-0))",
borderColor: "rgb(var(--border-default))",
}}
role="dialog"
aria-label="Chat parameters"
>
<h3
className="mb-3 text-xs font-semibold uppercase tracking-wide"
style={{ color: "rgb(var(--text-muted))" }}
>
Parameters
</h3>
{/* Temperature */}
<div className="mb-4">
<div className="mb-1.5 flex items-center justify-between">
<label
className="text-xs font-medium"
style={{ color: "rgb(var(--text-secondary))" }}
htmlFor="temperature-slider"
>
Temperature
</label>
<span
className="text-xs font-mono tabular-nums"
style={{ color: "rgb(var(--accent-primary))" }}
>
{temperature.toFixed(1)}
</span>
</div>
<input
id="temperature-slider"
type="range"
min={0}
max={2}
step={0.1}
value={temperature}
onChange={(e) => {
handleTemperatureChange(parseFloat(e.target.value));
}}
className="w-full h-1.5 rounded-full appearance-none cursor-pointer"
style={{
accentColor: "rgb(var(--accent-primary))",
backgroundColor: "rgb(var(--surface-2))",
}}
aria-label={`Temperature: ${temperature.toFixed(1)}`}
/>
<div
className="mt-1 flex justify-between text-[10px]"
style={{ color: "rgb(var(--text-muted))" }}
>
<span>Precise</span>
<span>Creative</span>
</div>
</div>
{/* Max Tokens */}
<div>
<label
className="mb-1.5 block text-xs font-medium"
style={{ color: "rgb(var(--text-secondary))" }}
htmlFor="max-tokens-input"
>
Max Tokens
</label>
<input
id="max-tokens-input"
type="number"
min={100}
max={32000}
step={100}
value={maxTokens}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (!isNaN(val) && val >= 100 && val <= 32000) {
handleMaxTokensChange(val);
}
}}
className="w-full rounded-md border px-2.5 py-1.5 text-xs outline-none focus:ring-2"
style={{
backgroundColor: "rgb(var(--surface-1))",
borderColor: "rgb(var(--border-default))",
color: "rgb(var(--text-primary))",
}}
aria-label="Maximum tokens"
/>
<p className="mt-1 text-[10px]" style={{ color: "rgb(var(--text-muted))" }}>
100 32,000
</p>
</div>
</div>
)}
</div>
</div>
{/* Input Container */}
<div
className="relative rounded-lg border transition-all duration-150"