feat(web): port chat UI — model selector, keybindings, thinking display, styled header
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful

This commit is contained in:
2026-03-19 20:42:48 -05:00
parent 25f880416a
commit 68e056ac91
11 changed files with 1848 additions and 260 deletions

View File

@@ -1,26 +1,97 @@
'use client';
/** Renders an in-progress assistant message from streaming text. */
import { useEffect, useMemo, useState } from 'react';
interface StreamingMessageProps {
text: string;
modelName?: string | null;
thinking?: string;
}
export function StreamingMessage({ text }: StreamingMessageProps): React.ReactElement {
const WAITING_QUIPS = [
'The AI is warming up... give it a moment.',
'Brewing some thoughts...',
'Summoning intelligence from the void...',
'Consulting the silicon oracle...',
'Teaching electrons to think...',
];
const TIMEOUT_QUIPS = [
'The model wandered off. Lets try to find it again.',
'Response is taking the scenic route.',
'That answer is clearly overthinking things.',
'Still working. Either brilliance or a detour.',
];
export function StreamingMessage({
text,
modelName,
thinking,
}: StreamingMessageProps): React.ReactElement {
const [elapsedMs, setElapsedMs] = useState(0);
useEffect(() => {
setElapsedMs(0);
const startedAt = Date.now();
const timer = window.setInterval(() => {
setElapsedMs(Date.now() - startedAt);
}, 1000);
return () => window.clearInterval(timer);
}, [text, modelName, thinking]);
const quip = useMemo(() => {
if (elapsedMs >= 18_000) {
return TIMEOUT_QUIPS[Math.floor((elapsedMs / 1000) % TIMEOUT_QUIPS.length)];
}
if (elapsedMs >= 4_000) {
return WAITING_QUIPS[Math.floor((elapsedMs / 1000) % WAITING_QUIPS.length)];
}
return null;
}, [elapsedMs]);
return (
<div className="flex justify-start">
<div className="max-w-[75%] rounded-xl border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-primary">
<div
className="max-w-[min(78ch,85%)] rounded-3xl border px-4 py-3 text-sm shadow-[var(--shadow-ms-sm)]"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)',
color: 'var(--color-text)',
}}
>
<div className="mb-2 flex items-center gap-2 text-[11px]">
<span className="font-medium text-[var(--color-text-2)]">Assistant</span>
{modelName ? (
<span className="rounded-full border border-[var(--color-border)] px-2 py-0.5 text-[var(--color-text-2)]">
{modelName}
</span>
) : null}
<span className="text-[var(--color-muted)]">{text ? 'Responding…' : 'Thinking…'}</span>
</div>
{text ? (
<div className="whitespace-pre-wrap break-words">{text}</div>
) : (
<div className="flex items-center gap-2 text-text-muted">
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500" />
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500 [animation-delay:0.2s]" />
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500 [animation-delay:0.4s]" />
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-[var(--color-ms-blue-500)]" />
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-[var(--color-ms-blue-500)] [animation-delay:0.2s]" />
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-[var(--color-ms-blue-500)] [animation-delay:0.4s]" />
</div>
)}
<div className="mt-1 flex items-center gap-1 text-xs text-text-muted">
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" />
{text ? 'Responding...' : 'Thinking...'}
{thinking ? (
<div
className="mt-3 rounded-2xl border px-3 py-2 font-mono text-xs whitespace-pre-wrap"
style={{
backgroundColor: 'var(--color-bg-deep)',
borderColor: 'var(--color-border)',
color: 'var(--color-text-2)',
}}
>
{thinking}
</div>
) : null}
<div className="mt-2 flex items-center gap-2 text-xs text-[var(--color-muted)]">
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-[var(--color-ms-blue-500)]" />
<span>{quip ?? (text ? 'Responding…' : 'Thinking…')}</span>
</div>
</div>
</div>