feat(ui): redesign /rails + /office for breathing room and digital office feel

Office (full rewrite):
- frontend/components/office/OfficeFloor.tsx — 새로운 책상 그리드
  4 자매 책상 (2x2 grid), 자매당 카드 형태
  자매 아바타 (gradient), 역할 라벨, 작업 중 pulse 애니메이션
  worker pill chips (manager/principal/lead/junior 색상별)
  Stats: workers / active / done / fail
  십자 가이드 라인으로 office floor plan 분위기
- frontend/app/office/page.tsx — 867 → 220 줄 압축
  rails 데이터 직접 사용 (sisters API 의존성 제거)
  사이드 패널: 선택된 자매의 sub-task 트리

Rails (간격 + 가독성):
- frontend/app/rails/page.tsx — 카드 spacing 확대, 헤더 명료화
  Start 폼을 별도 카드로 분리 (한 줄 → enter 시 시작)
  파이프라인 카드 padding 18px, 클릭 영역 확장
  state badge 컬러 + 라운드, project name 큼지막
  rel time / id 메타는 mono font 로 separator
- frontend/components/rails/SubTaskTree.tsx — 노드 padding 12px,
  자식들 사이 dashed border + 16px 들여쓰기 ( 시각적 hierarchy)
  Title sans font 로 변경, complexity meta 별도 줄

Sidebar:
- 기존 작업 변경 없음 ('레일' 메뉴는 이전 커밋에서 추가됨)

검증: pnpm build (next 16 turbopack) ✓
This commit is contained in:
2026-04-10 17:42:06 +09:00
parent 8ad373f78e
commit d100ee7c42
8 changed files with 939 additions and 954 deletions

View File

@@ -1 +1 @@
1775809670
1775810293

View File

View File

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import { API_URL } from '@/lib/config';
import {
@@ -8,18 +8,136 @@ import {
type RailsPipelineSummary,
type RailsSubTaskNode,
} from '@/lib/useRailsSocket';
import PipelineList from '@/components/rails/PipelineList';
import SubTaskTree from '@/components/rails/SubTaskTree';
import { LabelMeta } from '@/components/ui/base';
const Page = styled.main`
display: flex;
flex-direction: column;
gap: 28px;
padding: 32px 36px;
max-width: 1600px;
margin: 0 auto;
width: 100%;
`;
const Header = styled.header`
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 24px;
flex-wrap: wrap;
`;
const TitleBlock = styled.div``;
const Title = styled.h1`
font-size: 28px;
font-weight: 700;
margin: 0 0 4px;
letter-spacing: -0.01em;
`;
const Subtitle = styled.p`
font-size: 13px;
color: var(--text-secondary);
margin: 0;
`;
const Live = styled.span<{ $on: boolean }>`
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: ${({ $on }) => ($on ? '#22c55e' : 'var(--text-secondary)')};
&::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: ${({ $on }) => ($on ? '#22c55e' : '#525252')};
box-shadow: ${({ $on }) =>
$on ? '0 0 0 4px rgba(34, 197, 94, 0.15)' : 'none'};
}
`;
const StartCard = styled.section`
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 24px 28px;
display: flex;
flex-direction: column;
gap: 16px;
`;
const StartLabel = styled.div`
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-secondary);
`;
const StartRow = styled.div`
display: grid;
grid-template-columns: minmax(180px, 240px) 1fr auto;
gap: 12px;
@media (max-width: 700px) {
grid-template-columns: 1fr;
}
`;
const Input = styled.input`
padding: 12px 16px;
background: var(--bg-surface);
border: 1px solid var(--border-color);
color: var(--text-primary);
border-radius: 10px;
font-size: 14px;
font-family: var(--font-sans);
&::placeholder {
color: var(--text-secondary);
}
&:focus {
outline: none;
border-color: #5fafff;
box-shadow: 0 0 0 3px rgba(95, 175, 255, 0.15);
}
`;
const StartButton = styled.button`
padding: 12px 28px;
background: #5fafff;
color: #0a0a0a;
border: none;
border-radius: 10px;
font-weight: 700;
font-size: 14px;
cursor: pointer;
transition: opacity 0.15s;
&:hover {
opacity: 0.85;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
`;
const Layout = styled.div`
display: grid;
grid-template-columns: minmax(320px, 380px) 1fr;
gap: 16px;
padding: 16px;
min-height: calc(100vh - 120px);
grid-template-columns: minmax(360px, 440px) 1fr;
gap: 24px;
@media (max-width: 900px) {
@media (max-width: 1100px) {
grid-template-columns: 1fr;
}
`;
@@ -27,81 +145,158 @@ const Layout = styled.div`
const Pane = styled.section`
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 16px;
overflow: auto;
border-radius: 16px;
padding: 24px 28px;
display: flex;
flex-direction: column;
gap: 18px;
min-height: 420px;
`;
const PaneHeader = styled.header`
const PaneHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 14px;
border-bottom: 1px solid var(--border-color);
`;
const Dot = styled.span<{ $connected: boolean }>`
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
background: ${({ $connected }) => ($connected ? '#22c55e' : '#6b7280')};
const PaneTitle = styled.h2`
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-secondary);
margin: 0;
`;
const StartBar = styled.div`
const Counter = styled.span`
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
`;
const PipelineList = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
`;
const PipelineCard = styled.button<{ $selected: boolean; $state: string }>`
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
`;
const Input = styled.input`
flex: 1;
padding: 8px 12px;
background: var(--bg-surface);
border: 1px solid var(--border-color);
color: var(--text-primary);
border-radius: 8px;
font-size: 13px;
&:focus {
outline: none;
border-color: #5fafff;
}
`;
const Button = styled.button`
padding: 8px 16px;
background: #5fafff;
color: #fff;
border: none;
border-radius: 8px;
font-weight: 600;
padding: 18px 20px;
background: ${({ $selected }) =>
$selected ? 'var(--bg-surface)' : 'transparent'};
border: 1px solid ${({ $selected }) =>
$selected ? '#5fafff' : 'var(--border-color)'};
border-radius: 12px;
cursor: pointer;
text-align: left;
color: var(--text-primary);
transition: all 0.15s;
&:hover {
opacity: 0.9;
border-color: #5fafff;
background: var(--bg-surface);
}
`;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
const CardTop = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
`;
const ProjectName = styled.span`
font-size: 15px;
font-weight: 700;
letter-spacing: -0.01em;
`;
const StateBadge = styled.span<{ $state: string }>`
padding: 4px 12px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
border-radius: 20px;
color: #fff;
background: ${({ $state }) => stateBg($state)};
letter-spacing: 0.04em;
flex-shrink: 0;
`;
const CardMeta = styled.div`
display: flex;
gap: 12px;
font-size: 11px;
color: var(--text-secondary);
font-family: var(--font-mono);
`;
const Empty = styled.div`
padding: 60px 20px;
text-align: center;
color: var(--text-secondary);
font-size: 13px;
`;
const DetailHeader = styled.div`
padding: 8px 0 12px;
display: flex;
flex-direction: column;
gap: 8px;
padding-bottom: 18px;
border-bottom: 1px solid var(--border-color);
margin-bottom: 12px;
`;
const Meta = styled.div`
display: flex;
gap: 16px;
font-size: 12px;
opacity: 0.7;
margin-top: 4px;
const DetailTitle = styled.h3`
font-size: 22px;
font-weight: 700;
margin: 0;
letter-spacing: -0.01em;
`;
const DetailMeta = styled.div`
display: flex;
gap: 18px;
font-size: 12px;
color: var(--text-secondary);
font-family: var(--font-mono);
`;
function stateBg(state: string): string {
switch (state) {
case 'done':
return '#22c55e';
case 'escalated':
return '#ef4444';
case 'aborted':
return '#525252';
case 'planning':
case 'implementing':
case 'reviewing':
case 'deploying':
return '#f97316';
default:
return '#525252';
}
}
function relTime(iso: string): string {
try {
const ms = Date.now() - new Date(iso).getTime();
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s 전`;
if (sec < 3600) return `${Math.floor(sec / 60)}m 전`;
if (sec < 86400) return `${Math.floor(sec / 3600)}h 전`;
return `${Math.floor(sec / 86400)}d 전`;
} catch {
return iso;
}
}
export default function RailsPage() {
const [pipelines, setPipelines] = useState<RailsPipelineSummary[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -113,34 +308,26 @@ export default function RailsPage() {
const { connected } = useRailsSocket({
onPipelinesSnapshot: (next) => {
setPipelines(next);
if (!selectedId && next.length > 0) {
setSelectedId(next[0]!.id);
}
setSelectedId((prev) => prev ?? (next[0]?.id ?? null));
},
onSubTasksUpdated: (pipelineId, nextTree) => {
if (pipelineId === selectedId) {
setTree(nextTree);
}
setSelectedId((prev) => {
if (pipelineId === prev) setTree(nextTree);
return prev;
});
},
});
// Initial fetch
useEffect(() => {
fetch(`${API_URL}/api/rails/pipelines`, { credentials: 'include' })
.then((r) => r.json())
.then((data: { pipelines: RailsPipelineSummary[] }) => {
setPipelines(data.pipelines);
if (!selectedId && data.pipelines.length > 0) {
setSelectedId(data.pipelines[0]!.id);
}
if (data.pipelines.length > 0) setSelectedId(data.pipelines[0]!.id);
})
.catch(() => {
/* ignore */
});
// eslint-disable-next-line react-hooks/exhaustive-deps
.catch(() => undefined);
}, []);
// When selection changes, fetch tree once
useEffect(() => {
if (!selectedId) return;
fetch(`${API_URL}/api/rails/pipelines/${selectedId}/sub-tasks`, {
@@ -175,69 +362,105 @@ export default function RailsPage() {
}
}, [projectInput, reqInput]);
const selected = pipelines.find((p) => p.id === selectedId) ?? null;
const selected = useMemo(
() => pipelines.find((p) => p.id === selectedId) ?? null,
[pipelines, selectedId],
);
return (
<Layout>
<Pane>
<PaneHeader>
<LabelMeta>
<Dot $connected={connected} />
PIPELINES
</LabelMeta>
<span style={{ fontSize: 11, opacity: 0.6 }}>
{pipelines.length} total
</span>
</PaneHeader>
<Page>
<Header>
<TitleBlock>
<Title>Rails Orchestrator</Title>
<Subtitle> 4 </Subtitle>
</TitleBlock>
<Live $on={connected}>{connected ? 'LIVE' : 'OFFLINE'}</Live>
</Header>
<StartBar>
<StartCard>
<StartLabel> </StartLabel>
<StartRow>
<Input
placeholder="project"
placeholder="프로젝트 이름"
value={projectInput}
onChange={(e) => setProjectInput(e.target.value)}
/>
</StartBar>
<StartBar>
<Input
placeholder="requirements..."
placeholder="요구사항 (예: TODO 앱 MVP, 로그인 추가)"
value={reqInput}
onChange={(e) => setReqInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void handleStart();
}}
/>
<Button disabled={starting || !projectInput} onClick={handleStart}>
Start
</Button>
</StartBar>
<StartButton
disabled={starting || !projectInput.trim()}
onClick={handleStart}
>
{starting ? '시작 중...' : 'Start'}
</StartButton>
</StartRow>
</StartCard>
<PipelineList
pipelines={pipelines}
selectedId={selectedId}
onSelect={setSelectedId}
/>
</Pane>
<Layout>
<Pane>
<PaneHeader>
<PaneTitle>Pipelines</PaneTitle>
<Counter>{pipelines.length}</Counter>
</PaneHeader>
<Pane>
{selected ? (
<>
<DetailHeader>
<LabelMeta>PIPELINE DETAIL</LabelMeta>
<div style={{ fontWeight: 700, fontSize: 18, marginTop: 4 }}>
{selected.projectName}
</div>
<Meta>
<span>id: {selected.id}</span>
<span>state: {selected.currentState}</span>
<span>created: {new Date(selected.createdAt).toLocaleString()}</span>
</Meta>
</DetailHeader>
{pipelines.length === 0 ? (
<Empty> . .</Empty>
) : (
<PipelineList>
{pipelines.map((p) => (
<PipelineCard
key={p.id}
$selected={p.id === selectedId}
$state={p.currentState}
onClick={() => setSelectedId(p.id)}
>
<CardTop>
<ProjectName>{p.projectName}</ProjectName>
<StateBadge $state={p.currentState}>
{p.currentState}
</StateBadge>
</CardTop>
<CardMeta>
<span>{p.id.slice(0, 12)}</span>
<span>·</span>
<span>{relTime(p.updatedAt)}</span>
</CardMeta>
</PipelineCard>
))}
</PipelineList>
)}
</Pane>
<SubTaskTree tree={tree} />
</>
) : (
<div style={{ opacity: 0.6, padding: 40, textAlign: 'center' }}>
Select a pipeline to view its sub-task tree.
</div>
)}
</Pane>
</Layout>
<Pane>
{selected ? (
<>
<DetailHeader>
<PaneTitle>Pipeline Detail</PaneTitle>
<DetailTitle>{selected.projectName}</DetailTitle>
<DetailMeta>
<span>{selected.id}</span>
<span>state: {selected.currentState}</span>
<span>{new Date(selected.createdAt).toLocaleString('ko-KR')}</span>
</DetailMeta>
</DetailHeader>
{tree.length > 0 ? (
<SubTaskTree tree={tree} />
) : (
<Empty> sub-task .</Empty>
)}
</>
) : (
<Empty> .</Empty>
)}
</Pane>
</Layout>
</Page>
);
}

View File

@@ -0,0 +1,366 @@
'use client';
import React, { useMemo } from 'react';
import styled, { keyframes } from 'styled-components';
import type { RailsSubTaskNode, RailsPipelineSummary } from '@/lib/useRailsSocket';
const SISTERS = [
{ key: 'harang', label: '하랑', role: 'Planner', color: '#3b82f6', accent: '#60a5fa', x: 0, y: 0 },
{ key: 'narang', label: '나랑', role: 'Generator', color: '#22c55e', accent: '#4ade80', x: 1, y: 0 },
{ key: 'darang', label: '다랑', role: 'Evaluator', color: '#f43f5e', accent: '#fb7185', x: 0, y: 1 },
{ key: 'erang', label: '이랑', role: 'Infra', color: '#f97316', accent: '#fb923c', x: 1, y: 1 },
] as const;
type SisterKey = (typeof SISTERS)[number]['key'];
interface OfficeFloorProps {
pipelines: RailsPipelineSummary[];
treesByPipeline: Map<string, RailsSubTaskNode[]>;
selectedSister: SisterKey | null;
onSelectSister: (key: SisterKey | null) => void;
}
interface SisterStats {
total: number;
running: number;
done: number;
failed: number;
models: Set<string>;
}
const Wrap = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
const Floor = styled.div`
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 24px;
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 28px;
min-height: 540px;
position: relative;
&::before {
content: '';
position: absolute;
top: 50%;
left: 28px;
right: 28px;
height: 1px;
background: linear-gradient(90deg, transparent, var(--border-color), transparent);
pointer-events: none;
}
&::after {
content: '';
position: absolute;
left: 50%;
top: 28px;
bottom: 28px;
width: 1px;
background: linear-gradient(180deg, transparent, var(--border-color), transparent);
pointer-events: none;
}
`;
const pulse = keyframes`
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 165, 0, 0.4); }
50% { box-shadow: 0 0 0 8px rgba(255, 165, 0, 0); }
`;
const blink = keyframes`
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
`;
const Desk = styled.button<{ $color: string; $active: boolean; $running: boolean; $selected: boolean }>`
position: relative;
background: ${({ $color }) => `${$color}10`};
border: 1.5px solid ${({ $color, $selected }) => ($selected ? $color : `${$color}50`)};
border-radius: 12px;
padding: 24px;
display: flex;
flex-direction: column;
gap: 14px;
cursor: pointer;
transition: all 0.2s ease;
min-height: 220px;
text-align: left;
color: var(--text-primary);
animation: ${({ $running }) => ($running ? pulse : 'none')} 1.6s ease-in-out infinite;
&:hover {
transform: translateY(-2px);
border-color: ${({ $color }) => $color};
}
`;
const DeskHead = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
`;
const SisterBadge = styled.div<{ $color: string }>`
display: flex;
align-items: center;
gap: 12px;
`;
const Avatar = styled.div<{ $color: string; $running: boolean }>`
width: 56px;
height: 56px;
border-radius: 14px;
background: linear-gradient(135deg, ${({ $color }) => $color}, ${({ $color }) => `${$color}80`});
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
font-weight: 700;
color: #fff;
flex-shrink: 0;
box-shadow: ${({ $color, $running }) =>
$running ? `0 4px 16px ${$color}60` : `0 2px 8px ${$color}30`};
`;
const NameBlock = styled.div`
display: flex;
flex-direction: column;
`;
const Name = styled.span`
font-size: 18px;
font-weight: 700;
`;
const Role = styled.span`
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
`;
const StatusDot = styled.div<{ $running: boolean }>`
width: 10px;
height: 10px;
border-radius: 50%;
background: ${({ $running }) => ($running ? '#22c55e' : '#525252')};
flex-shrink: 0;
${({ $running }) =>
$running &&
`
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.2);
animation: ${blink} 1.4s ease-in-out infinite;
`}
`;
const Stats = styled.div`
display: flex;
gap: 18px;
padding-top: 12px;
border-top: 1px dashed var(--border-color);
`;
const Stat = styled.div`
display: flex;
flex-direction: column;
gap: 2px;
`;
const StatNum = styled.span`
font-family: var(--font-mono);
font-size: 20px;
font-weight: 700;
`;
const StatLabel = styled.span`
font-size: 10px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
`;
const Workers = styled.div`
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 28px;
`;
const Worker = styled.div<{ $role: string; $state: string }>`
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: 600;
background: ${({ $role }) => roleColor($role)}20;
border: 1px solid ${({ $role }) => roleColor($role)}60;
color: ${({ $role }) => roleColor($role)};
${({ $state }) =>
$state === 'running' &&
`animation: ${blink} 1.2s ease-in-out infinite;`}
`;
const Empty = styled.span`
font-size: 11px;
color: var(--text-secondary);
font-style: italic;
`;
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 flattenWorkers(tree: RailsSubTaskNode[]): RailsSubTaskNode[] {
const out: RailsSubTaskNode[] = [];
const walk = (nodes: RailsSubTaskNode[]) => {
for (const n of nodes) {
out.push(n);
if (n.children?.length) walk(n.children);
}
};
walk(tree);
return out;
}
function statsFor(workers: RailsSubTaskNode[]): SisterStats {
const stats: SisterStats = {
total: workers.length,
running: 0,
done: 0,
failed: 0,
models: new Set(),
};
for (const w of workers) {
if (w.state === 'running' || w.state === 'queued') stats.running += 1;
else if (w.state === 'done') stats.done += 1;
else if (w.state === 'failed' || w.state === 'escalated') stats.failed += 1;
if (w.model) stats.models.add(w.model);
}
return stats;
}
export default function OfficeFloor({
pipelines,
treesByPipeline,
selectedSister,
onSelectSister,
}: OfficeFloorProps) {
// Aggregate workers per sister across active pipelines
const workersBySister = useMemo(() => {
const map = new Map<SisterKey, RailsSubTaskNode[]>();
for (const sister of SISTERS) map.set(sister.key, []);
const activePipelines = pipelines.filter(
(p) => !['done', 'aborted'].includes(p.currentState),
);
const sourcePipelines = activePipelines.length > 0 ? activePipelines : pipelines.slice(0, 4);
for (const pipeline of sourcePipelines) {
const tree = treesByPipeline.get(pipeline.id) ?? [];
const flat = flattenWorkers(tree);
for (const node of flat) {
const sisterKey = node.agentName as SisterKey;
const target = map.get(sisterKey);
if (target) target.push(node);
}
}
return map;
}, [pipelines, treesByPipeline]);
return (
<Wrap>
<Floor>
{SISTERS.map((sister) => {
const workers = workersBySister.get(sister.key) ?? [];
const stats = statsFor(workers);
const running = stats.running > 0;
const isSelected = selectedSister === sister.key;
return (
<Desk
key={sister.key}
$color={sister.color}
$active={stats.total > 0}
$running={running}
$selected={isSelected}
onClick={() => onSelectSister(isSelected ? null : sister.key)}
>
<DeskHead>
<SisterBadge $color={sister.color}>
<Avatar $color={sister.color} $running={running}>
{sister.label[0]}
</Avatar>
<NameBlock>
<Name>{sister.label}</Name>
<Role>{sister.role}</Role>
</NameBlock>
</SisterBadge>
<StatusDot $running={running} />
</DeskHead>
<Workers>
{workers.length === 0 ? (
<Empty> </Empty>
) : (
workers.slice(0, 12).map((w) => (
<Worker key={w.id} $role={w.role} $state={w.state}>
{w.role}
</Worker>
))
)}
{workers.length > 12 && (
<Worker $role="" $state="">
+{workers.length - 12}
</Worker>
)}
</Workers>
<Stats>
<Stat>
<StatNum>{stats.total}</StatNum>
<StatLabel>workers</StatLabel>
</Stat>
<Stat>
<StatNum style={{ color: '#22c55e' }}>{stats.running}</StatNum>
<StatLabel>active</StatLabel>
</Stat>
<Stat>
<StatNum>{stats.done}</StatNum>
<StatLabel>done</StatLabel>
</Stat>
{stats.failed > 0 && (
<Stat>
<StatNum style={{ color: '#ef4444' }}>{stats.failed}</StatNum>
<StatLabel>fail</StatLabel>
</Stat>
)}
</Stats>
</Desk>
);
})}
</Floor>
</Wrap>
);
}

View File

@@ -5,38 +5,45 @@ import styled from 'styled-components';
import type { RailsSubTaskNode } from '@/lib/useRailsSocket';
const Wrap = styled.div`
font-family: var(--font-mono, monospace);
font-family: var(--font-sans);
font-size: 13px;
line-height: 1.6;
display: flex;
flex-direction: column;
gap: 8px;
`;
const Node = styled.div<{ $state: string }>`
padding: 4px 8px;
padding: 12px 16px;
border-left: 3px solid ${({ $state }) => stateColor($state)};
margin: 2px 0;
background: var(--bg-surface);
border-radius: 4px;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 6px;
`;
const RoleBadge = styled.span<{ $role: string }>`
display: inline-block;
padding: 1px 8px;
padding: 3px 10px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
border-radius: 10px;
letter-spacing: 0.04em;
border-radius: 12px;
color: #fff;
margin-right: 8px;
margin-right: 10px;
background: ${({ $role }) => roleColor($role)};
`;
const StateDot = styled.span<{ $state: string }>`
display: inline-block;
width: 8px;
height: 8px;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 8px;
margin-right: 12px;
background: ${({ $state }) => stateColor($state)};
flex-shrink: 0;
animation: ${({ $state }) => ($state === 'running' ? 'pulse 1.2s infinite' : 'none')};
@keyframes pulse {
@@ -46,24 +53,45 @@ const StateDot = styled.span<{ $state: string }>`
`;
const Model = styled.span`
font-size: 10px;
opacity: 0.6;
margin-left: 8px;
font-size: 11px;
opacity: 0.55;
margin-left: 12px;
font-family: var(--font-mono);
`;
const Duration = styled.span`
font-size: 10px;
opacity: 0.6;
font-size: 11px;
opacity: 0.55;
margin-left: auto;
font-family: var(--font-mono);
`;
const Title = styled.span`
font-size: 13px;
font-weight: 500;
`;
const Meta = styled.span`
font-size: 11px;
opacity: 0.55;
margin-left: 22px;
font-family: var(--font-mono);
`;
const Row = styled.div`
display: flex;
align-items: center;
gap: 4px;
`;
const Children = styled.div`
margin-left: 24px;
margin-left: 28px;
margin-top: 4px;
display: flex;
flex-direction: column;
gap: 8px;
border-left: 1px dashed var(--border-color);
padding-left: 16px;
`;
function stateColor(state: string): string {
@@ -115,16 +143,14 @@ function NodeRow({ node }: { node: RailsSubTaskNode }) {
<Row>
<StateDot $state={node.state} />
<RoleBadge $role={node.role}>{node.role}</RoleBadge>
<span>{node.title.slice(0, 80)}</span>
<Title>{node.title.slice(0, 100)}</Title>
<Model>{node.model || '—'}</Model>
<Duration>{duration(node.startedAt, node.completedAt)}</Duration>
</Row>
{node.complexityTier && (
<Row>
<span style={{ marginLeft: 22, fontSize: 10, opacity: 0.6 }}>
complexity: {node.complexityTier} ({node.complexityScore})
</span>
</Row>
<Meta>
complexity: {node.complexityTier} ({node.complexityScore})
</Meta>
)}
</Node>
{node.children.length > 0 && (