feat(rails): SubTaskDetailDrawer 컴팩트화 — collapsible 섹션 + 이벤트 필터

자기야 피드백: 드로어가 너무 길어서 보기 힘듦. LLM 응답 + 전체 이벤트 로그
+ 자식 노드 다 펼쳐져 있어서 하나 클릭하면 화면 한 페이지가 다 차버림.

수정:
- LLM 응답: 280 자 미리보기 + "더 보기 (+N chars)" 토글. 긴 마크다운이
  디폴트로 화면을 먹지 않음. 응답 길이를 헤더 옆 배지로 미리 표시
- 하위 노드: 디폴트 접힘. 헤더에 개수 배지, 클릭하면 펼침
- 이벤트 로그: 디폴트 접힘. 펼치면 두 칩 (milestones / all) 으로 필터링
  - milestones: spawned/completed/failed/escalated 만
  - all: started/progress/output 까지 전부
  대부분 디버깅엔 milestones 만 보면 충분
- 새 sub-task 클릭 시 expanded 상태 전부 리셋

새 styled components: CollapsibleHeader, ChevronIcon, SectionCount, ChipRow,
Chip, ShowMoreButton (재사용 가능)

대시보드 한 화면이 디폴트로 절반 이하로 줄어듦. 정보 손실 0 — 클릭으로
다 볼 수 있음.
This commit is contained in:
2026-04-11 01:49:46 +09:00
parent 13e95f4ace
commit 837ab28d07

View File

@@ -184,6 +184,88 @@ const SectionLabel = styled.h3`
margin: 0;
`;
const CollapsibleHeader = styled.button`
display: flex;
align-items: center;
gap: 8px;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0;
margin: 0;
cursor: pointer;
text-align: left;
width: fit-content;
&:hover {
color: #5fafff;
}
`;
const ChevronIcon = styled.span<{ $open: boolean }>`
display: inline-block;
font-size: 9px;
width: 12px;
text-align: center;
transform: ${({ $open }) => ($open ? 'rotate(90deg)' : 'rotate(0deg)')};
transition: transform 0.15s ease;
`;
const SectionCount = styled.span`
font-size: 10px;
font-weight: 600;
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 1px 8px;
color: var(--text-primary);
`;
const ChipRow = styled.div`
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 4px;
`;
const Chip = styled.button<{ $active: boolean }>`
font-size: 10px;
padding: 3px 10px;
border-radius: 10px;
background: ${({ $active }) => ($active ? '#5fafff' : 'transparent')};
color: ${({ $active }) => ($active ? '#fff' : 'var(--text-secondary)')};
border: 1px solid ${({ $active }) => ($active ? '#5fafff' : 'var(--border-color)')};
cursor: pointer;
font-family: inherit;
&:hover {
border-color: #5fafff;
color: ${({ $active }) => ($active ? '#fff' : '#5fafff')};
}
`;
const ShowMoreButton = styled.button`
display: block;
margin-top: 8px;
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
font-size: 11px;
padding: 6px 14px;
border-radius: 8px;
cursor: pointer;
font-family: inherit;
&:hover {
border-color: #5fafff;
color: #5fafff;
}
`;
const Grid = styled.div`
display: grid;
grid-template-columns: repeat(2, 1fr);
@@ -526,6 +608,17 @@ interface Props {
onClose: () => void;
}
type EventFilterMode = 'milestones' | 'all';
const MILESTONE_EVENT_TYPES = new Set([
'spawned',
'completed',
'failed',
'escalated',
]);
const LLM_PREVIEW_LIMIT = 280;
export default function SubTaskDetailDrawer({ subTaskId, onClose }: Props) {
const [detail, setDetail] = useState<SubTaskDetail | null>(null);
const [loading, setLoading] = useState(true);
@@ -533,12 +626,30 @@ export default function SubTaskDetailDrawer({ subTaskId, onClose }: Props) {
path: string;
source: FileViewerSource;
} | null>(null);
const [llmExpanded, setLlmExpanded] = useState(false);
const [eventsExpanded, setEventsExpanded] = useState(false);
const [childrenExpanded, setChildrenExpanded] = useState(false);
const [eventFilter, setEventFilter] = useState<EventFilterMode>('milestones');
// Reset transient UI state when switching to a different sub-task
useEffect(() => {
setLlmExpanded(false);
setEventsExpanded(false);
setChildrenExpanded(false);
setEventFilter('milestones');
}, [subTaskId]);
const llmResult = useMemo(
() => (detail ? parseResult(detail.resultJson) : null),
[detail],
);
const filteredEvents = useMemo(() => {
if (!detail) return [];
if (eventFilter === 'all') return detail.events;
return detail.events.filter((ev) => MILESTONE_EVENT_TYPES.has(ev.eventType));
}, [detail, eventFilter]);
const artifacts = useMemo(() => {
if (!detail)
return {
@@ -731,7 +842,14 @@ export default function SubTaskDetailDrawer({ subTaskId, onClose }: Props) {
{llmResult && llmResult.text && (
<Section>
<SectionLabel>LLM </SectionLabel>
<SectionLabel>
LLM
{llmResult.text.length > LLM_PREVIEW_LIMIT && (
<SectionCount style={{ marginLeft: 8 }}>
{llmResult.text.length.toLocaleString()} chars
</SectionCount>
)}
</SectionLabel>
<LlmOutput $ok={llmResult.ok}>
<OutputHeader>
<OutputStatusDot $ok={llmResult.ok} />
@@ -739,7 +857,16 @@ export default function SubTaskDetailDrawer({ subTaskId, onClose }: Props) {
{llmResult.ok ? 'success' : 'failed'} · {detail.model || 'default'}
</span>
</OutputHeader>
<ReactMarkdown>{llmResult.text}</ReactMarkdown>
<ReactMarkdown>
{llmExpanded || llmResult.text.length <= LLM_PREVIEW_LIMIT
? llmResult.text
: llmResult.text.slice(0, LLM_PREVIEW_LIMIT) + '…'}
</ReactMarkdown>
{llmResult.text.length > LLM_PREVIEW_LIMIT && (
<ShowMoreButton onClick={() => setLlmExpanded(!llmExpanded)}>
{llmExpanded ? '접기' : `더 보기 (+${(llmResult.text.length - LLM_PREVIEW_LIMIT).toLocaleString()} chars)`}
</ShowMoreButton>
)}
</LlmOutput>
</Section>
)}
@@ -753,41 +880,67 @@ export default function SubTaskDetailDrawer({ subTaskId, onClose }: Props) {
{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>
<CollapsibleHeader onClick={() => setChildrenExpanded(!childrenExpanded)}>
<ChevronIcon $open={childrenExpanded}></ChevronIcon>
<SectionCount>{detail.childrenList.length}</SectionCount>
</CollapsibleHeader>
{childrenExpanded && (
<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>
<CollapsibleHeader onClick={() => setEventsExpanded(!eventsExpanded)}>
<ChevronIcon $open={eventsExpanded}></ChevronIcon>
<SectionCount>{detail.events.length}</SectionCount>
</CollapsibleHeader>
{eventsExpanded && (
<>
<ChipRow>
<Chip
$active={eventFilter === 'milestones'}
onClick={() => setEventFilter('milestones')}
>
milestones ({detail.events.filter((e) => MILESTONE_EVENT_TYPES.has(e.eventType)).length})
</Chip>
<Chip
$active={eventFilter === 'all'}
onClick={() => setEventFilter('all')}
>
all ({detail.events.length})
</Chip>
</ChipRow>
<Events>
{filteredEvents.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>
</>