Files
stack/apps/web/src/components/chat/streaming-message.tsx
Jason Woltje 68e056ac91
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
feat(web): port chat UI — model selector, keybindings, thinking display, styled header
2026-03-19 20:42:48 -05:00

100 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useMemo, useState } from 'react';
interface StreamingMessageProps {
text: string;
modelName?: string | null;
thinking?: string;
}
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-[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-[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>
)}
{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>
);
}