Build the 4자매 office dashboard with SVG scene, agent state visualization, context panel, direct chat workspace, pipeline panel, and server health panel. - frontend/app/office/page.tsx: Main office page (sisters + ops data, WS live + polling fallback, selected agent state, chat toggle) - frontend/components/office/OfficeScene.tsx: SVG 2D office floor plan with 4 fixed sister desks, 17 subagent nodes, handoff connector lines, conference room, and per-state animations (idle/thinking/tool_calling/ speaking/error) - frontend/components/office/ContextPanel.tsx: Right context panel showing selected sister or subagent detail, current task, subagent list, chat/detail links - frontend/components/office/ChatWorkspace.tsx: Direct chat workspace with sister tabs, message timeline, streaming-ready layout; Gateway connection pending notice (honest fallback labeling) - frontend/components/office/PipelinePanel.tsx: Bottom pipeline panel with sprint/workflow nodes and flow animations - frontend/components/office/ServerHealthPanel.tsx: Server health grid for 4 sisters + Dev + Docker with live/snapshot/fallback labels - frontend/components/common/Sidebar.tsx: Add /office nav item Data labeling: sisters live via WS (snapshot fallback), subagent states derived/fallback, pipeline snapshot, chat gateway-pending. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
456 lines
11 KiB
TypeScript
456 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import React from 'react';
|
|
import styled from 'styled-components';
|
|
import Link from 'next/link';
|
|
import type { SisterNode, SubAgent, SisterName, AgentState, SelectedAgent } from './OfficeScene';
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
interface ContextPanelProps {
|
|
selected: SelectedAgent | null;
|
|
sisters: SisterNode[];
|
|
onOpenChat: (sisterName: SisterName) => void;
|
|
onClear: () => void;
|
|
}
|
|
|
|
// ─── Styled Components ────────────────────────────────────────────────────────
|
|
|
|
const Panel = styled.aside`
|
|
width: 300px;
|
|
flex-shrink: 0;
|
|
border-left: 1px solid var(--border-color);
|
|
background: var(--bg-surface);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow-y: auto;
|
|
|
|
@media (max-width: 1199px) {
|
|
width: 260px;
|
|
}
|
|
|
|
@media (max-width: 767px) {
|
|
width: 100%;
|
|
border-left: none;
|
|
border-top: 1px solid var(--border-color);
|
|
max-height: 220px;
|
|
}
|
|
`;
|
|
|
|
const PanelHeader = styled.div`
|
|
padding: var(--space-md) var(--space-lg);
|
|
border-bottom: 1px solid var(--border-color);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
flex-shrink: 0;
|
|
`;
|
|
|
|
const PanelTitle = styled.div`
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
`;
|
|
|
|
const ClearBtn = styled.button`
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
line-height: 1;
|
|
|
|
&:hover {
|
|
color: var(--text-primary);
|
|
}
|
|
`;
|
|
|
|
const PanelBody = styled.div`
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: var(--space-lg);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-lg);
|
|
`;
|
|
|
|
const EmptyState = styled.div`
|
|
color: var(--text-secondary);
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
text-align: center;
|
|
padding: var(--space-xl) 0;
|
|
`;
|
|
|
|
const AgentHeader = styled.div`
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: var(--space-md);
|
|
`;
|
|
|
|
const AgentEmoji = styled.div`
|
|
font-size: 28px;
|
|
line-height: 1;
|
|
flex-shrink: 0;
|
|
`;
|
|
|
|
const AgentMeta = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
`;
|
|
|
|
const AgentName = styled.div`
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
`;
|
|
|
|
const AgentRole = styled.div`
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
`;
|
|
|
|
const AgentType = styled.div`
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
`;
|
|
|
|
const StateRow = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-sm);
|
|
`;
|
|
|
|
const StateDot = styled.span<{ $state: AgentState }>`
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
background: ${({ $state }) => {
|
|
const colors: Record<AgentState, string> = {
|
|
idle: '#444',
|
|
thinking: '#2979FF',
|
|
tool_calling: '#FF9800',
|
|
speaking: '#00BFA5',
|
|
error: '#FF1744',
|
|
};
|
|
return colors[$state];
|
|
}};
|
|
`;
|
|
|
|
const StateLabel = styled.span<{ $state: AgentState }>`
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: ${({ $state }) => {
|
|
const colors: Record<AgentState, string> = {
|
|
idle: 'var(--text-secondary)',
|
|
thinking: '#2979FF',
|
|
tool_calling: '#FF9800',
|
|
speaking: '#00BFA5',
|
|
error: '#FF1744',
|
|
};
|
|
return colors[$state];
|
|
}};
|
|
`;
|
|
|
|
const Section = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-sm);
|
|
`;
|
|
|
|
const SectionLabel = styled.div`
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
border-bottom: 1px solid var(--border-color);
|
|
padding-bottom: 4px;
|
|
`;
|
|
|
|
const SectionValue = styled.div`
|
|
font-size: 12px;
|
|
color: var(--text-primary);
|
|
line-height: 1.5;
|
|
`;
|
|
|
|
const CurrentTaskBox = styled.div`
|
|
font-size: 12px;
|
|
color: #58A6FF;
|
|
padding: var(--space-sm) var(--space-md);
|
|
background: rgba(88, 166, 255, 0.06);
|
|
border-left: 2px solid #58A6FF;
|
|
line-height: 1.5;
|
|
`;
|
|
|
|
const SubagentGrid = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
`;
|
|
|
|
const SubagentRow = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-sm);
|
|
font-size: 12px;
|
|
color: var(--text-primary);
|
|
`;
|
|
|
|
const SubDot = styled.span<{ $state: AgentState }>`
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
background: ${({ $state }) => {
|
|
const colors: Record<AgentState, string> = {
|
|
idle: '#444',
|
|
thinking: '#2979FF',
|
|
tool_calling: '#FF9800',
|
|
speaking: '#00BFA5',
|
|
error: '#FF1744',
|
|
};
|
|
return colors[$state];
|
|
}};
|
|
`;
|
|
|
|
const SubName = styled.span`
|
|
color: var(--text-secondary);
|
|
font-size: 11px;
|
|
font-family: var(--font-mono);
|
|
`;
|
|
|
|
const ActionRow = styled.div`
|
|
display: flex;
|
|
gap: var(--space-sm);
|
|
flex-wrap: wrap;
|
|
`;
|
|
|
|
const ActionBtn = styled.button`
|
|
border: 1px solid var(--border-color);
|
|
background: transparent;
|
|
color: var(--text-secondary);
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
padding: var(--space-xs) var(--space-sm);
|
|
cursor: pointer;
|
|
font-family: var(--font-mono);
|
|
transition: all 0.15s;
|
|
|
|
&:hover {
|
|
border-color: var(--border-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
`;
|
|
|
|
const ActionLink = styled(Link)`
|
|
border: 1px solid var(--border-color);
|
|
background: transparent;
|
|
color: var(--text-secondary);
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
padding: var(--space-xs) var(--space-sm);
|
|
text-decoration: none;
|
|
font-family: var(--font-mono);
|
|
transition: all 0.15s;
|
|
display: inline-block;
|
|
|
|
&:hover {
|
|
border-color: var(--border-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
`;
|
|
|
|
const FallbackNote = styled.div`
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
color: var(--text-secondary);
|
|
opacity: 0.5;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
`;
|
|
|
|
// ─── Sister display data ──────────────────────────────────────────────────────
|
|
|
|
const SISTER_EMOJIS: Record<SisterName, string> = {
|
|
harang: '🦊',
|
|
narang: '🦊',
|
|
darang: '🐱',
|
|
erang: '🐺',
|
|
};
|
|
|
|
const SISTER_DISPLAY: Record<SisterName, string> = {
|
|
harang: '하랑이',
|
|
narang: '나랑이',
|
|
darang: '다랑이',
|
|
erang: '이랑이',
|
|
};
|
|
|
|
// ─── Component ────────────────────────────────────────────────────────────────
|
|
|
|
function SisterDetail({
|
|
sister,
|
|
onOpenChat,
|
|
}: {
|
|
sister: SisterNode;
|
|
onOpenChat: (name: SisterName) => void;
|
|
}) {
|
|
return (
|
|
<>
|
|
<AgentHeader>
|
|
<AgentEmoji>{SISTER_EMOJIS[sister.name]}</AgentEmoji>
|
|
<AgentMeta>
|
|
<AgentName>{SISTER_DISPLAY[sister.name]}</AgentName>
|
|
<AgentRole>{sister.role}</AgentRole>
|
|
<AgentType>main agent</AgentType>
|
|
</AgentMeta>
|
|
</AgentHeader>
|
|
|
|
<StateRow>
|
|
<StateDot $state={sister.state} />
|
|
<StateLabel $state={sister.state}>{sister.state}</StateLabel>
|
|
</StateRow>
|
|
|
|
{sister.currentTask && (
|
|
<Section>
|
|
<SectionLabel>current task</SectionLabel>
|
|
<CurrentTaskBox>{sister.currentTask}</CurrentTaskBox>
|
|
</Section>
|
|
)}
|
|
|
|
<Section>
|
|
<SectionLabel>subagents ({sister.subagents.length})</SectionLabel>
|
|
<SubagentGrid>
|
|
{sister.subagents.map((sub) => (
|
|
<SubagentRow key={sub.id}>
|
|
<SubDot $state={sub.state} />
|
|
<span>{sub.label}</span>
|
|
<SubName>· {sub.state}</SubName>
|
|
</SubagentRow>
|
|
))}
|
|
</SubagentGrid>
|
|
<FallbackNote>subagent state: fallback</FallbackNote>
|
|
</Section>
|
|
|
|
<ActionRow>
|
|
<ActionBtn onClick={() => onOpenChat(sister.name)}>
|
|
채팅
|
|
</ActionBtn>
|
|
<ActionLink href={`/sisters/${sister.name}`}>
|
|
상세
|
|
</ActionLink>
|
|
</ActionRow>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function SubagentDetail({
|
|
sub,
|
|
sister,
|
|
onOpenChat,
|
|
}: {
|
|
sub: SubAgent;
|
|
sister: SisterNode;
|
|
onOpenChat: (name: SisterName) => void;
|
|
}) {
|
|
return (
|
|
<>
|
|
<AgentHeader>
|
|
<AgentEmoji>🤖</AgentEmoji>
|
|
<AgentMeta>
|
|
<AgentName>{sub.label}</AgentName>
|
|
<AgentRole>{sub.name}</AgentRole>
|
|
<AgentType>subagent · {SISTER_DISPLAY[sister.name]} 소속</AgentType>
|
|
</AgentMeta>
|
|
</AgentHeader>
|
|
|
|
<StateRow>
|
|
<StateDot $state={sub.state} />
|
|
<StateLabel $state={sub.state}>{sub.state}</StateLabel>
|
|
</StateRow>
|
|
|
|
<Section>
|
|
<SectionLabel>parent</SectionLabel>
|
|
<SectionValue>
|
|
{SISTER_EMOJIS[sister.name]} {SISTER_DISPLAY[sister.name]} · {sister.role}
|
|
</SectionValue>
|
|
</Section>
|
|
|
|
<FallbackNote>subagent state: fallback · no direct api</FallbackNote>
|
|
|
|
<ActionRow>
|
|
<ActionBtn onClick={() => onOpenChat(sister.name)}>
|
|
{SISTER_DISPLAY[sister.name]} 채팅
|
|
</ActionBtn>
|
|
<ActionLink href={`/sisters/${sister.name}`}>
|
|
자매 상세
|
|
</ActionLink>
|
|
</ActionRow>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function ContextPanel({
|
|
selected,
|
|
sisters,
|
|
onOpenChat,
|
|
onClear,
|
|
}: ContextPanelProps) {
|
|
const getSister = (name: SisterName) => sisters.find((s) => s.name === name);
|
|
|
|
let content: React.ReactNode = (
|
|
<EmptyState>
|
|
에이전트를 선택하면<br />
|
|
상세 정보가 표시됩니다.
|
|
</EmptyState>
|
|
);
|
|
|
|
if (selected?.type === 'sister') {
|
|
const sisterData = getSister(selected.name);
|
|
if (sisterData) {
|
|
content = (
|
|
<SisterDetail
|
|
sister={sisterData}
|
|
onOpenChat={onOpenChat}
|
|
/>
|
|
);
|
|
}
|
|
} else if (selected?.type === 'subagent') {
|
|
const sisterData = getSister(selected.sister);
|
|
const subData = sisterData?.subagents.find((s) => s.id === selected.id);
|
|
if (sisterData && subData) {
|
|
content = (
|
|
<SubagentDetail
|
|
sub={subData}
|
|
sister={sisterData}
|
|
onOpenChat={onOpenChat}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Panel>
|
|
<PanelHeader>
|
|
<PanelTitle>context panel</PanelTitle>
|
|
{selected && <ClearBtn onClick={onClear}>✕</ClearBtn>}
|
|
</PanelHeader>
|
|
<PanelBody>{content}</PanelBody>
|
|
</Panel>
|
|
);
|
|
}
|