Files
stack/apps/web/src/components/chat/MessageList.tsx
Jason Woltje ac1f2c176f
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix: Resolve all ESLint errors and warnings in web package
Fixes all 542 ESLint problems in the web package to achieve 0 errors and 0 warnings.

Changes:
- Fixed 144 issues: nullish coalescing, return types, unused variables
- Fixed 118 issues: unnecessary conditions, type safety, template literals
- Fixed 79 issues: non-null assertions, unsafe assignments, empty functions
- Fixed 67 issues: explicit return types, promise handling, enum comparisons
- Fixed 45 final warnings: missing return types, optional chains
- Fixed 25 typecheck-related issues: async/await, type assertions, formatting
- Fixed JSX.Element namespace errors across 90+ files

All Quality Rails violations resolved. Lint and typecheck both pass with 0 problems.

Files modified: 118 components, tests, hooks, and utilities

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-31 00:10:03 -06:00

333 lines
11 KiB
TypeScript

"use client";
import { useCallback, useState } from "react";
import type { Message } from "@/hooks/useChat";
interface MessageListProps {
messages: Message[];
isLoading: boolean;
loadingQuip?: string | null;
}
/**
* Parse thinking content from message.
* Extracts <thinking>...</thinking> or <think>...</think> blocks.
*/
function parseThinking(content: string): { thinking: string | null; response: string } {
// Match <thinking>...</thinking> or <think>...</think> blocks
const thinkingRegex = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi;
const matches = content.match(thinkingRegex);
if (!matches) {
return { thinking: null, response: content };
}
// Extract thinking content
let thinking = "";
for (const match of matches) {
const innerContent = match.replace(/<\/?(?:thinking|think)>/gi, "");
thinking += innerContent.trim() + "\n";
}
// Remove thinking blocks from response
const response = content.replace(thinkingRegex, "").trim();
const trimmedThinking = thinking.trim();
return {
thinking: trimmedThinking.length > 0 ? trimmedThinking : null,
response,
};
}
export function MessageList({
messages,
isLoading,
loadingQuip,
}: MessageListProps): React.JSX.Element {
return (
<div className="space-y-6" role="log" aria-label="Chat messages">
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{isLoading && <LoadingIndicator {...(loadingQuip != null && { quip: loadingQuip })} />}
</div>
);
}
function MessageBubble({ message }: { message: Message }): React.JSX.Element {
const isUser = message.role === "user";
const [copied, setCopied] = useState(false);
const [thinkingExpanded, setThinkingExpanded] = useState(false);
// Parse thinking from content (or use pre-parsed thinking field)
const { thinking, response } = message.thinking
? { thinking: message.thinking, response: message.content }
: parseThinking(message.content);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(response);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
} catch (err) {
// Silently fail - clipboard copy is non-critical
void err;
}
}, [response]);
return (
<div
className={`group flex gap-4 message-animate ${isUser ? "flex-row-reverse" : ""}`}
role="article"
aria-label={`${isUser ? "Your" : "AI Assistant"} message`}
>
{/* Avatar */}
<div
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg text-xs font-semibold"
style={{
backgroundColor: isUser ? "rgb(var(--surface-2))" : "rgb(var(--accent-primary))",
color: isUser ? "rgb(var(--text-secondary))" : "white",
}}
aria-hidden="true"
>
{isUser ? "You" : "AI"}
</div>
{/* Message Content */}
<div className={`flex max-w-[85%] flex-col gap-1.5 ${isUser ? "items-end" : "items-start"}`}>
{/* Message Header */}
<div
className={`flex items-center gap-2 text-xs ${isUser ? "flex-row-reverse" : ""}`}
style={{ color: "rgb(var(--text-muted))" }}
>
<span className="font-medium" style={{ color: "rgb(var(--text-secondary))" }}>
{isUser ? "You" : "AI Assistant"}
</span>
{/* Model indicator for assistant messages */}
{!isUser && message.model && (
<span
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
style={{
backgroundColor: "rgb(var(--surface-2))",
color: "rgb(var(--text-tertiary))",
}}
title={message.provider ? `Provider: ${message.provider}` : undefined}
>
{message.model}
</span>
)}
{/* Token usage indicator for assistant messages */}
{!isUser && message.totalTokens !== undefined && message.totalTokens > 0 && (
<span
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
style={{
backgroundColor: "rgb(var(--surface-2))",
color: "rgb(var(--text-muted))",
}}
title={`Prompt: ${message.promptTokens?.toLocaleString() ?? "0"} tokens, Completion: ${message.completionTokens?.toLocaleString() ?? "0"} tokens`}
>
{formatTokenCount(message.totalTokens)} tokens
</span>
)}
<span aria-label={`Sent at ${formatTime(message.createdAt)}`}>
{formatTime(message.createdAt)}
</span>
</div>
{/* Thinking Section - Collapsible */}
{thinking && !isUser && (
<div
className="rounded-lg overflow-hidden"
style={{
backgroundColor: "rgb(var(--surface-1))",
border: "1px solid rgb(var(--border-default))",
}}
>
<button
onClick={() => {
setThinkingExpanded(!thinkingExpanded);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-medium hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
style={{ color: "rgb(var(--text-secondary))" }}
aria-expanded={thinkingExpanded}
>
<svg
className={`h-3.5 w-3.5 transition-transform duration-200 ${thinkingExpanded ? "rotate-90" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path d="m9 18 6-6-6-6" />
</svg>
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<span>Thinking</span>
<span className="ml-auto text-xs" style={{ color: "rgb(var(--text-muted))" }}>
{thinkingExpanded ? "Hide" : "Show"} reasoning
</span>
</button>
{thinkingExpanded && (
<div
className="px-3 pb-3 text-xs leading-relaxed whitespace-pre-wrap font-mono"
style={{
color: "rgb(var(--text-secondary))",
borderTop: "1px solid rgb(var(--border-default))",
}}
>
{thinking}
</div>
)}
</div>
)}
{/* Message Body */}
<div
className="relative rounded-lg px-4 py-3"
style={{
backgroundColor: isUser ? "rgb(var(--accent-primary))" : "rgb(var(--surface-0))",
color: isUser ? "white" : "rgb(var(--text-primary))",
border: isUser ? "none" : "1px solid rgb(var(--border-default))",
}}
>
<p className="whitespace-pre-wrap text-sm leading-relaxed">{response}</p>
{/* Copy Button - appears on hover */}
<button
onClick={handleCopy}
className="absolute -right-2 -top-2 rounded-md border p-1.5 opacity-0 transition-all group-hover:opacity-100 focus:opacity-100"
style={{
backgroundColor: "rgb(var(--surface-0))",
borderColor: "rgb(var(--border-default))",
color: copied ? "rgb(var(--semantic-success))" : "rgb(var(--text-muted))",
}}
aria-label={copied ? "Copied!" : "Copy message"}
title={copied ? "Copied!" : "Copy to clipboard"}
>
{copied ? (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</button>
</div>
</div>
</div>
);
}
function LoadingIndicator({ quip }: { quip?: string | null }): React.JSX.Element {
return (
<div className="flex gap-4 message-animate" role="status" aria-label="AI is typing">
{/* Avatar */}
<div
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg text-xs font-semibold"
style={{ backgroundColor: "rgb(var(--accent-primary))" }}
aria-hidden="true"
>
<span className="text-white">AI</span>
</div>
{/* Loading Content */}
<div className="flex flex-col gap-1.5">
<div
className="flex items-center gap-2 text-xs"
style={{ color: "rgb(var(--text-muted))" }}
>
<span className="font-medium" style={{ color: "rgb(var(--text-secondary))" }}>
AI Assistant
</span>
<span>thinking...</span>
</div>
<div
className="rounded-lg border px-4 py-3"
style={{
backgroundColor: "rgb(var(--surface-0))",
borderColor: "rgb(var(--border-default))",
}}
>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<span
className="h-1.5 w-1.5 rounded-full animate-bounce"
style={{
backgroundColor: "rgb(var(--accent-primary))",
animationDelay: "0ms",
}}
/>
<span
className="h-1.5 w-1.5 rounded-full animate-bounce"
style={{
backgroundColor: "rgb(var(--accent-primary))",
animationDelay: "150ms",
}}
/>
<span
className="h-1.5 w-1.5 rounded-full animate-bounce"
style={{
backgroundColor: "rgb(var(--accent-primary))",
animationDelay: "300ms",
}}
/>
</div>
{quip && (
<span
className="text-sm italic animate-fade-in"
style={{ color: "rgb(var(--text-muted))" }}
>
{quip}
</span>
)}
</div>
</div>
</div>
</div>
);
}
function formatTime(isoString: string): string {
try {
const date = new Date(isoString);
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
} catch {
return "";
}
}
function formatTokenCount(tokens: number): string {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(1)}M`;
} else if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}K`;
}
return tokens.toString();
}