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:
@@ -1 +1 @@
|
||||
1775809670
|
||||
1775810293
|
||||
|
||||
0
.claude/state/session-events.lock
Normal file
0
.claude/state/session-events.lock
Normal file
0
.claude/state/session.events.jsonl
Normal file
0
.claude/state/session.events.jsonl
Normal file
0
.claude/state/session.json
Normal file
0
.claude/state/session.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
366
frontend/components/office/OfficeFloor.tsx
Normal file
366
frontend/components/office/OfficeFloor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user