Complete frontend design overhaul replacing achromatic shadcn/ui defaults with an indigo-branded (#6366F1), status-aware, dark-mode-enabled token system. Phase 1 — Theme Foundation: - Replace all CSS tokens in index.css with v2 light/dark mode values - Add 24 status tokens (6 statuses × 4 variants), 22 terminal tokens, 7 diff tokens, 5 shadow tokens, 9 transition/animation tokens, 10 z-index tokens, 10-step extended indigo scale - Install Geist Sans/Mono variable fonts (public/fonts/) - Extend tailwind.config.ts with all new token utilities - Add dark mode flash-prevention script in index.html - Add status-pulse and shimmer keyframe animations - Add global focus-visible styles and reduced-motion media query Phase 2 — ThemeProvider + Toggle: - ThemeProvider context with system preference listener - 3-state ThemeToggle (Sun/Monitor/Moon) - Radix tooltip primitive for tooltips - localStorage persistence with 'cw-theme' key Phase 3 — Shared Components + Token Migration: - StatusDot: mapEntityStatus() maps raw statuses to 6 semantic variants - StatusBadge: uses status token bg/fg/border classes - Badge: 6 new status variants + xs size - EmptyState, ErrorState, SaveIndicator shared patterns - CommandPalette: Cmd+K search with fuzzy matching, keyboard nav - Skeleton with shimmer animation + SkeletonCard composite layouts - KeyboardShortcutHint, NavBadge, enhanced Sonner config - Migrate ALL hardcoded Tailwind colors to token classes across AgentOutputViewer, review/*, ProgressBar, AccountCard, InitiativeHeader, DependencyIndicator, PipelineTaskCard, PreviewPanel, ChangeSetBanner, MessageCard, PhaseDetailPanel Phase 4 — App Layout Overhaul: - Single 48px row header with CW logo, nav with NavBadge counts, Cmd+K search button, ThemeToggle, HealthDot - Remove max-w-7xl from header/main; pages control own widths - ConnectionBanner for offline/reconnecting states - BrowserTitleUpdater with running/questions counts - useGlobalKeyboard (1-4 nav, Cmd+K), useConnectionStatus hooks - Per-page width wrappers (initiatives max-w-6xl, settings max-w-4xl) Phase 5 — Page-Level Token Migration: - ReviewSidebar: all hardcoded green/orange/red → status/diff tokens - CommentThread: resolved state → status-success tokens - Settings health: green → status-success-dot
242 lines
8.5 KiB
TypeScript
242 lines
8.5 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { ArrowDown, Pause, Play, AlertCircle, Square } from "lucide-react";
|
|
import { trpc } from "@/lib/trpc";
|
|
import { useSubscriptionWithErrorHandling } from "@/hooks";
|
|
import {
|
|
type ParsedMessage,
|
|
getMessageStyling,
|
|
parseAgentOutput,
|
|
} from "@/lib/parse-agent-output";
|
|
|
|
interface AgentOutputViewerProps {
|
|
agentId: string;
|
|
agentName?: string;
|
|
status?: string;
|
|
onStop?: (id: string) => void;
|
|
}
|
|
|
|
export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentOutputViewerProps) {
|
|
const [messages, setMessages] = useState<ParsedMessage[]>([]);
|
|
const [follow, setFollow] = useState(true);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
// Accumulate raw JSONL: initial query data + live subscription chunks
|
|
const rawBufferRef = useRef<string>('');
|
|
|
|
// Load initial/historical output
|
|
const outputQuery = trpc.getAgentOutput.useQuery(
|
|
{ id: agentId },
|
|
{
|
|
refetchOnWindowFocus: false,
|
|
}
|
|
);
|
|
|
|
// Subscribe to live output with error handling
|
|
const subscription = useSubscriptionWithErrorHandling(
|
|
() => trpc.onAgentOutput.useSubscription({ agentId }),
|
|
{
|
|
onData: (event: any) => {
|
|
// TrackedEnvelope shape: { id, data: { agentId, data: string } }
|
|
const raw = event?.data?.data ?? event?.data;
|
|
const chunk = typeof raw === 'string' ? raw : JSON.stringify(raw);
|
|
rawBufferRef.current += chunk;
|
|
setMessages(parseAgentOutput(rawBufferRef.current));
|
|
},
|
|
onError: (error) => {
|
|
console.error('Agent output subscription error:', error);
|
|
},
|
|
autoReconnect: true,
|
|
maxReconnectAttempts: 3,
|
|
}
|
|
);
|
|
|
|
// Set initial output when query loads
|
|
useEffect(() => {
|
|
if (outputQuery.data) {
|
|
rawBufferRef.current = outputQuery.data;
|
|
setMessages(parseAgentOutput(outputQuery.data));
|
|
}
|
|
}, [outputQuery.data]);
|
|
|
|
// Reset output when agent changes
|
|
useEffect(() => {
|
|
rawBufferRef.current = '';
|
|
setMessages([]);
|
|
setFollow(true);
|
|
}, [agentId]);
|
|
|
|
// Auto-scroll to bottom when following
|
|
useEffect(() => {
|
|
if (follow && containerRef.current) {
|
|
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
|
}
|
|
}, [messages, follow]);
|
|
|
|
// Handle scroll to detect user scrolling up
|
|
function handleScroll() {
|
|
if (!containerRef.current) return;
|
|
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
|
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
|
|
if (!isAtBottom && follow) {
|
|
setFollow(false);
|
|
}
|
|
}
|
|
|
|
// Jump to bottom
|
|
function scrollToBottom() {
|
|
if (containerRef.current) {
|
|
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
|
setFollow(true);
|
|
}
|
|
}
|
|
|
|
const isLoading = outputQuery.isLoading;
|
|
const hasOutput = messages.length > 0;
|
|
|
|
return (
|
|
<div className="flex flex-col h-full rounded-lg border border-terminal-border overflow-hidden">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between border-b border-terminal-border bg-terminal px-4 py-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-terminal-fg font-mono">
|
|
{agentName ? `Output: ${agentName}` : "Agent Output"}
|
|
</span>
|
|
{subscription.error && (
|
|
<div className="flex items-center gap-1 text-terminal-error" title={subscription.error.message}>
|
|
<AlertCircle className="h-3 w-3" />
|
|
<span className="text-xs">Connection error</span>
|
|
</div>
|
|
)}
|
|
{subscription.isConnecting && (
|
|
<span className="text-xs text-terminal-warning">Connecting...</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{onStop && (status === "running" || status === "waiting_for_input") && (
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => onStop(agentId)}
|
|
className="h-7"
|
|
>
|
|
<Square className="mr-1 h-3 w-3" />
|
|
Stop
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setFollow(!follow)}
|
|
className="h-7 text-terminal-muted hover:text-terminal-fg hover:bg-white/5"
|
|
>
|
|
{follow ? (
|
|
<>
|
|
<Pause className="mr-1 h-3 w-3" />
|
|
Following
|
|
</>
|
|
) : (
|
|
<>
|
|
<Play className="mr-1 h-3 w-3" />
|
|
Paused
|
|
</>
|
|
)}
|
|
</Button>
|
|
{!follow && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={scrollToBottom}
|
|
className="h-7 text-terminal-muted hover:text-terminal-fg hover:bg-white/5"
|
|
>
|
|
<ArrowDown className="mr-1 h-3 w-3" />
|
|
Jump to bottom
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Output content */}
|
|
<div
|
|
ref={containerRef}
|
|
onScroll={handleScroll}
|
|
className="flex-1 overflow-y-auto bg-terminal p-4"
|
|
>
|
|
{isLoading ? (
|
|
<div className="text-terminal-muted text-sm">Loading output...</div>
|
|
) : !hasOutput ? (
|
|
<div className="text-terminal-muted text-sm">No output yet...</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{messages.map((message, index) => (
|
|
<div key={index} className={getMessageStyling(message.type)}>
|
|
{message.type === 'system' && (
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="secondary" className="text-xs bg-terminal-border text-terminal-system">System</Badge>
|
|
<span className="text-xs text-terminal-muted">{message.content}</span>
|
|
</div>
|
|
)}
|
|
|
|
{message.type === 'text' && (
|
|
<div className="font-mono text-sm whitespace-pre-wrap text-terminal-fg">
|
|
{message.content}
|
|
</div>
|
|
)}
|
|
|
|
{message.type === 'tool_call' && (
|
|
<div className="border-l-2 border-terminal-tool pl-3 py-1">
|
|
<Badge variant="default" className="mb-1 text-xs">
|
|
{message.meta?.toolName}
|
|
</Badge>
|
|
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap">
|
|
{message.content}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{message.type === 'tool_result' && (
|
|
<div className="border-l-2 border-terminal-result pl-3 py-1 bg-white/[0.02]">
|
|
<Badge variant="outline" className="mb-1 text-xs text-terminal-result border-terminal-result">
|
|
Result
|
|
</Badge>
|
|
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap">
|
|
{message.content}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{message.type === 'error' && (
|
|
<div className="border-l-2 border-terminal-error pl-3 py-1 bg-terminal-error/10">
|
|
<Badge variant="destructive" className="mb-1 text-xs">
|
|
Error
|
|
</Badge>
|
|
<div className="font-mono text-xs text-terminal-error whitespace-pre-wrap">
|
|
{message.content}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{message.type === 'session_end' && (
|
|
<div className="border-t border-terminal-border pt-2 mt-4">
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant={message.meta?.isError ? "destructive" : "default"} className="text-xs">
|
|
{message.content}
|
|
</Badge>
|
|
{message.meta?.cost && (
|
|
<span className="text-xs text-terminal-muted">${message.meta.cost.toFixed(4)}</span>
|
|
)}
|
|
{message.meta?.duration && (
|
|
<span className="text-xs text-terminal-muted">{(message.meta.duration / 1000).toFixed(1)}s</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|