Files
hanarang-dashboard/frontend/components/rails/SubTaskDetailDrawer.tsx

747 lines
19 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 React, { useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import ReactMarkdown from 'react-markdown';
import { API_URL } from '@/lib/config';
import SisterAvatar from '@/components/common/SisterAvatar';
const SISTER_NAMES = new Set(['harang', 'narang', 'darang', 'erang']);
function parseResult(
raw: string | null,
): { text: string; ok: boolean; extra?: Record<string, unknown> } | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
if (typeof parsed === 'object' && parsed !== null) {
const text = typeof parsed.text === 'string' ? parsed.text : '';
const ok = parsed.ok !== false;
const { text: _t, ok: _o, ...rest } = parsed as {
text?: unknown;
ok?: unknown;
} & Record<string, unknown>;
void _t;
void _o;
return { text, ok, extra: Object.keys(rest).length > 0 ? rest : undefined };
}
} catch {
return { text: raw, ok: true };
}
return null;
}
interface SubTaskEvent {
id: number;
eventType: string;
payload: unknown;
timestamp: string;
}
interface ParentLink {
id: string;
role: string;
title: string;
}
interface ChildSummary {
id: string;
role: string;
agentName: string;
title: string;
state: string;
model: string;
startedAt: string | null;
completedAt: string | null;
}
interface SubTaskDetail {
id: string;
pipelineId: string;
parentId: string | null;
role: string;
agentName: string;
title: string;
description: string;
state: string;
complexityScore: number | null;
complexityTier: string | null;
model: string;
resultJson: string | null;
errorReason: string | null;
startedAt: string | null;
completedAt: string | null;
createdAt: string;
parents: ParentLink[];
childrenList: ChildSummary[];
events: SubTaskEvent[];
}
const Backdrop = styled.div`
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 50;
display: flex;
justify-content: flex-end;
`;
const Drawer = styled.div`
width: min(640px, 95vw);
height: 100%;
background: var(--bg-main);
border-left: 1px solid var(--border-color);
overflow-y: auto;
display: flex;
flex-direction: column;
`;
const Header = styled.div`
position: sticky;
top: 0;
background: var(--bg-main);
padding: 24px 28px 18px;
border-bottom: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 10px;
z-index: 1;
`;
const TopRow = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
`;
const RoleBadge = styled.span<{ $role: string }>`
padding: 4px 12px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
border-radius: 14px;
color: #fff;
background: ${({ $role }) => roleColor($role)};
`;
const Close = styled.button`
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 6px 14px;
border-radius: 8px;
cursor: pointer;
font-size: 12px;
&:hover {
border-color: #5fafff;
}
`;
const Title = styled.h2`
font-size: 20px;
font-weight: 700;
margin: 0;
letter-spacing: -0.01em;
word-break: break-word;
`;
const Breadcrumb = styled.div`
display: flex;
gap: 6px;
font-size: 11px;
color: var(--text-secondary);
font-family: var(--font-mono);
flex-wrap: wrap;
`;
const Body = styled.div`
padding: 24px 28px;
display: flex;
flex-direction: column;
gap: 24px;
`;
const Section = styled.section`
display: flex;
flex-direction: column;
gap: 10px;
`;
const SectionLabel = styled.h3`
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-secondary);
margin: 0;
`;
const Grid = styled.div`
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
`;
const Field = styled.div`
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 4px;
`;
const FieldLabel = styled.span`
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary);
`;
const FieldValue = styled.span`
font-size: 13px;
font-weight: 500;
font-family: var(--font-mono);
word-break: break-all;
`;
const StateBadge = styled.span<{ $state: string }>`
display: inline-block;
padding: 3px 10px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
border-radius: 12px;
color: #fff;
background: ${({ $state }) => stateColor($state)};
`;
const Description = styled.p`
font-size: 13px;
line-height: 1.6;
color: var(--text-primary);
background: var(--bg-input);
padding: 14px 16px;
border-radius: 10px;
margin: 0;
white-space: pre-wrap;
word-break: break-word;
`;
const LlmOutput = styled.div<{ $ok: boolean }>`
background: var(--bg-input);
border: 1px solid ${({ $ok }) => ($ok ? 'var(--border-color)' : '#ef444460')};
border-left: 4px solid ${({ $ok }) => ($ok ? '#5fafff' : '#ef4444')};
padding: 18px 22px;
border-radius: 10px;
font-size: 13px;
line-height: 1.75;
color: var(--text-primary);
& > *:first-child { margin-top: 0; }
& > *:last-child { margin-bottom: 0; }
h1, h2, h3, h4, h5, h6 {
font-size: 14px;
font-weight: 700;
margin: 14px 0 6px;
color: var(--text-primary);
}
p { margin: 10px 0; }
ul, ol {
margin: 10px 0;
padding-left: 22px;
}
li { margin: 4px 0; }
code {
background: var(--bg-surface);
padding: 1px 6px;
border-radius: 4px;
font-size: 12px;
font-family: var(--font-mono);
color: #5fafff;
}
pre {
background: var(--bg-surface);
border: 1px solid var(--border-color);
padding: 12px 14px;
border-radius: 8px;
overflow-x: auto;
margin: 10px 0;
code {
background: transparent;
padding: 0;
color: inherit;
}
}
blockquote {
border-left: 3px solid var(--border-color);
padding-left: 12px;
margin: 10px 0;
color: var(--text-secondary);
}
a {
color: #5fafff;
text-decoration: none;
&:hover { text-decoration: underline; }
}
strong { font-weight: 700; }
`;
const OutputHeader = styled.div`
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
`;
const OutputStatusDot = styled.span<{ $ok: boolean }>`
width: 8px;
height: 8px;
border-radius: 50%;
background: ${({ $ok }) => ($ok ? '#22c55e' : '#ef4444')};
`;
const ChildList = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const ChildCard = styled.div`
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: 10px;
font-size: 12px;
`;
const ChildTitle = styled.span`
flex: 1;
font-size: 12px;
`;
const Events = styled.ol`
display: flex;
flex-direction: column;
gap: 8px;
list-style: none;
padding: 0;
margin: 0;
`;
const EventRow = styled.li`
display: flex;
gap: 12px;
padding: 10px 14px;
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: 10px;
font-family: var(--font-mono);
font-size: 11px;
`;
const EventType = styled.span<{ $type: string }>`
font-weight: 700;
text-transform: uppercase;
color: ${({ $type }) => eventColor($type)};
flex-shrink: 0;
width: 90px;
`;
const EventTime = styled.span`
color: var(--text-secondary);
flex-shrink: 0;
`;
const EventPayload = styled.pre`
margin: 0;
font-size: 11px;
white-space: pre-wrap;
word-break: break-all;
flex: 1;
color: var(--text-primary);
opacity: 0.8;
`;
const FileList = styled.div`
display: flex;
flex-direction: column;
gap: 6px;
`;
const FileRow = styled.div`
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: 8px;
font-family: var(--font-mono);
font-size: 12px;
`;
const FileBadge = styled.span<{ $kind: string }>`
padding: 2px 8px;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
border-radius: 10px;
color: #fff;
background: ${({ $kind }) => {
switch ($kind) {
case 'html':
case 'htm':
return '#f97316';
case 'js':
case 'javascript':
case 'jsx':
return '#eab308';
case 'ts':
case 'typescript':
case 'tsx':
return '#3b82f6';
case 'css':
case 'scss':
return '#ec4899';
case 'json':
case 'yaml':
return '#8b5cf6';
case 'md':
case 'markdown':
return '#6b7280';
case 'url':
return '#22c55e';
default:
return '#525252';
}
}};
`;
const FilePath = styled.code`
flex: 1;
word-break: break-all;
color: var(--text-primary);
`;
const Loading = styled.div`
padding: 60px 20px;
text-align: center;
color: var(--text-secondary);
`;
function roleColor(role: string): string {
switch (role) {
case 'manager':
return '#8b5cf6';
case 'principal':
return '#3b82f6';
case 'lead':
return '#f97316';
case 'junior':
return '#22c55e';
default:
return '#6b7280';
}
}
function stateColor(state: string): string {
switch (state) {
case 'done':
return '#22c55e';
case 'running':
return '#f97316';
case 'failed':
case 'escalated':
return '#ef4444';
case 'queued':
return '#6b7280';
default:
return '#525252';
}
}
function eventColor(type: string): string {
switch (type) {
case 'spawned':
return '#8b5cf6';
case 'started':
return '#f97316';
case 'progress':
case 'output':
return '#5fafff';
case 'completed':
return '#22c55e';
case 'failed':
case 'escalated':
return '#ef4444';
default:
return '#6b7280';
}
}
function duration(start: string | null, end: string | null): string {
if (!start) return '—';
const s = new Date(start).getTime();
const e = end ? new Date(end).getTime() : Date.now();
const ms = e - s;
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(2)}s`;
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
}
interface Props {
subTaskId: string;
onClose: () => void;
}
export default function SubTaskDetailDrawer({ subTaskId, onClose }: Props) {
const [detail, setDetail] = useState<SubTaskDetail | null>(null);
const [loading, setLoading] = useState(true);
const llmResult = useMemo(
() => (detail ? parseResult(detail.resultJson) : null),
[detail],
);
const artifacts = useMemo(() => {
if (!detail) return { mainFile: '', codeFiles: [] as Array<{ path: string; lang: string }>, deployUrl: '' };
let mainFile = '';
let codeFiles: Array<{ path: string; lang: string }> = [];
let deployUrl = '';
for (const ev of detail.events) {
if (ev.eventType === 'completed' && ev.payload && typeof ev.payload === 'object') {
const p = ev.payload as Record<string, unknown>;
if (typeof p.file === 'string' && p.file) mainFile = p.file;
if (Array.isArray(p.extractedFiles)) {
codeFiles = (p.extractedFiles as Array<{ path?: string; lang?: string }>)
.filter((f): f is { path: string; lang: string } => !!f?.path)
.map((f) => ({ path: f.path, lang: f.lang ?? '' }));
}
if (typeof p.deployUrl === 'string') deployUrl = p.deployUrl;
if (typeof p.repoUrl === 'string' && !deployUrl) deployUrl = p.repoUrl;
}
}
return { mainFile, codeFiles, deployUrl };
}, [detail]);
useEffect(() => {
setLoading(true);
fetch(`${API_URL}/api/rails/sub-tasks/${subTaskId}`, {
credentials: 'include',
})
.then((r) => r.json())
.then((data: SubTaskDetail) => {
setDetail(data);
setLoading(false);
})
.catch(() => setLoading(false));
}, [subTaskId]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onClose]);
return (
<Backdrop onClick={onClose}>
<Drawer onClick={(e) => e.stopPropagation()}>
{loading || !detail ? (
<Loading> ...</Loading>
) : (
<>
<Header>
<TopRow>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{SISTER_NAMES.has(detail.agentName) && (
<SisterAvatar name={detail.agentName} size={40} />
)}
<RoleBadge $role={detail.role}>{detail.role}</RoleBadge>
</div>
<Close onClick={onClose}> ESC</Close>
</TopRow>
<Title>{detail.title}</Title>
{detail.parents.length > 0 && (
<Breadcrumb>
{detail.parents.map((p, i) => (
<React.Fragment key={p.id}>
<span>{p.role}</span>
<span></span>
{i === detail.parents.length - 1 && <span></span>}
</React.Fragment>
))}
</Breadcrumb>
)}
</Header>
<Body>
<Section>
<SectionLabel> / </SectionLabel>
<Grid>
<Field>
<FieldLabel>State</FieldLabel>
<FieldValue>
<StateBadge $state={detail.state}>{detail.state}</StateBadge>
</FieldValue>
</Field>
<Field>
<FieldLabel>Agent</FieldLabel>
<FieldValue>{detail.agentName}</FieldValue>
</Field>
<Field>
<FieldLabel>Model</FieldLabel>
<FieldValue>{detail.model || '—'}</FieldValue>
</Field>
<Field>
<FieldLabel>Duration</FieldLabel>
<FieldValue>
{duration(detail.startedAt, detail.completedAt)}
</FieldValue>
</Field>
{detail.complexityTier && (
<Field>
<FieldLabel>Complexity</FieldLabel>
<FieldValue>
{detail.complexityTier} ({detail.complexityScore})
</FieldValue>
</Field>
)}
<Field>
<FieldLabel>ID</FieldLabel>
<FieldValue>{detail.id.slice(0, 16)}...</FieldValue>
</Field>
</Grid>
</Section>
{detail.description && (
<Section>
<SectionLabel></SectionLabel>
<Description>{detail.description}</Description>
</Section>
)}
{detail.errorReason && (
<Section>
<SectionLabel></SectionLabel>
<Description>{detail.errorReason}</Description>
</Section>
)}
{(artifacts.mainFile || artifacts.codeFiles.length > 0 || artifacts.deployUrl) && (
<Section>
<SectionLabel></SectionLabel>
<FileList>
{artifacts.mainFile && (
<FileRow>
<FileBadge $kind="md">log</FileBadge>
<FilePath>{artifacts.mainFile}</FilePath>
</FileRow>
)}
{artifacts.codeFiles.map((f) => (
<FileRow key={f.path}>
<FileBadge $kind={f.lang || 'code'}>{f.lang || 'code'}</FileBadge>
<FilePath>files/{f.path}</FilePath>
</FileRow>
))}
{artifacts.deployUrl && (
<FileRow>
<FileBadge $kind="url">url</FileBadge>
<FilePath>
<a href={artifacts.deployUrl} target="_blank" rel="noopener noreferrer" style={{ color: '#5fafff' }}>
{artifacts.deployUrl}
</a>
</FilePath>
</FileRow>
)}
</FileList>
</Section>
)}
{llmResult && llmResult.text && (
<Section>
<SectionLabel>LLM </SectionLabel>
<LlmOutput $ok={llmResult.ok}>
<OutputHeader>
<OutputStatusDot $ok={llmResult.ok} />
<span style={{ fontSize: 11, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
{llmResult.ok ? 'success' : 'failed'} · {detail.model || 'default'}
</span>
</OutputHeader>
<ReactMarkdown>{llmResult.text}</ReactMarkdown>
</LlmOutput>
</Section>
)}
{llmResult && !llmResult.text && detail.resultJson && (
<Section>
<SectionLabel> ( )</SectionLabel>
<Description>{detail.resultJson}</Description>
</Section>
)}
{detail.childrenList.length > 0 && (
<Section>
<SectionLabel>
({detail.childrenList.length})
</SectionLabel>
<ChildList>
{detail.childrenList.map((c) => (
<ChildCard key={c.id}>
<RoleBadge $role={c.role}>{c.role}</RoleBadge>
<ChildTitle>{c.title}</ChildTitle>
<StateBadge $state={c.state}>{c.state}</StateBadge>
<span style={{ fontSize: 10, opacity: 0.6 }}>
{duration(c.startedAt, c.completedAt)}
</span>
</ChildCard>
))}
</ChildList>
</Section>
)}
<Section>
<SectionLabel> ({detail.events.length})</SectionLabel>
<Events>
{detail.events.map((ev) => (
<EventRow key={ev.id}>
<EventType $type={ev.eventType}>{ev.eventType}</EventType>
<EventTime>
{new Date(ev.timestamp).toLocaleTimeString('ko-KR')}
</EventTime>
<EventPayload>
{typeof ev.payload === 'string'
? ev.payload
: JSON.stringify(ev.payload)}
</EventPayload>
</EventRow>
))}
</Events>
</Section>
</Body>
</>
)}
</Drawer>
</Backdrop>
);
}