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:
@@ -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>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user