|
|
|
|
@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
|
|
|
import styled from 'styled-components';
|
|
|
|
|
import { API_URL, POLL_INTERVAL_MS } from '@/lib/config';
|
|
|
|
|
import { useSocket } from '@/lib/useSocket';
|
|
|
|
|
import SisterAvatar from '@/components/common/SisterAvatar';
|
|
|
|
|
import OfficeScene, {
|
|
|
|
|
type AgentState,
|
|
|
|
|
type SisterName,
|
|
|
|
|
@@ -274,6 +275,10 @@ const PageMeta = styled.div`
|
|
|
|
|
color: var(--text-secondary);
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
letter-spacing: 0.06em;
|
|
|
|
|
|
|
|
|
|
@media (max-width: 767px) {
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const FreshnessBar = styled.div`
|
|
|
|
|
@@ -281,6 +286,11 @@ const FreshnessBar = styled.div`
|
|
|
|
|
gap: var(--space-md);
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
margin-left: auto;
|
|
|
|
|
|
|
|
|
|
@media (max-width: 767px) {
|
|
|
|
|
margin-left: 0;
|
|
|
|
|
width: 100%;
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const FreshnessBadge = styled.span<{ $type: 'live' | 'snapshot' | 'fallback' }>`
|
|
|
|
|
@@ -294,14 +304,15 @@ const FreshnessBadge = styled.span<{ $type: 'live' | 'snapshot' | 'fallback' }>`
|
|
|
|
|
opacity: 0.8;
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const MainArea = styled.div`
|
|
|
|
|
/* ─── Desktop layout (≥768px): scene + context side-by-side ─── */
|
|
|
|
|
const DesktopMain = styled.div`
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 0;
|
|
|
|
|
min-height: 0;
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
|
|
|
|
@media (max-width: 767px) {
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
@@ -318,7 +329,7 @@ const ChatArea = styled.div`
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
|
|
|
|
@media (max-width: 767px) {
|
|
|
|
|
height: 460px;
|
|
|
|
|
height: 400px;
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
@@ -326,6 +337,10 @@ const BottomPanels = styled.div`
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: var(--space-lg);
|
|
|
|
|
|
|
|
|
|
@media (max-width: 767px) {
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const LoadingOverlay = styled.div`
|
|
|
|
|
@@ -340,6 +355,224 @@ const LoadingOverlay = styled.div`
|
|
|
|
|
letter-spacing: 0.08em;
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
/* ─── Mobile layout (<768px) ─── */
|
|
|
|
|
const MobileFlow = styled.div`
|
|
|
|
|
display: none;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: var(--space-lg);
|
|
|
|
|
|
|
|
|
|
@media (max-width: 767px) {
|
|
|
|
|
display: flex;
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const MobileSisterGrid = styled.div`
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: 1fr 1fr;
|
|
|
|
|
gap: var(--space-sm);
|
|
|
|
|
|
|
|
|
|
@media (max-width: 359px) {
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const STATE_CARD_COLORS: Record<AgentState, string> = {
|
|
|
|
|
idle: '#444',
|
|
|
|
|
thinking: '#2979FF',
|
|
|
|
|
tool_calling: '#FF9800',
|
|
|
|
|
speaking: '#00BFA5',
|
|
|
|
|
error: '#FF1744',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const MobileSisterCard = styled.button<{ $state: AgentState; $selected: boolean }>`
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: var(--space-sm);
|
|
|
|
|
padding: var(--space-sm) var(--space-md);
|
|
|
|
|
border: 1px solid ${({ $state }) => STATE_CARD_COLORS[$state]};
|
|
|
|
|
background: ${({ $selected }) => ($selected ? 'rgba(255,255,255,0.04)' : 'var(--bg-surface)')};
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
text-align: left;
|
|
|
|
|
font-family: inherit;
|
|
|
|
|
color: var(--text-primary);
|
|
|
|
|
outline: ${({ $selected }) => ($selected ? '1px solid var(--text-primary)' : 'none')};
|
|
|
|
|
outline-offset: -1px;
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const MobileCardMeta = styled.div`
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 2px;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
flex: 1;
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const MobileCardName = styled.div`
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const MobileCardState = styled.div<{ $state: AgentState }>`
|
|
|
|
|
font-family: var(--font-mono);
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
letter-spacing: 0.06em;
|
|
|
|
|
color: ${({ $state }) => STATE_CARD_COLORS[$state]};
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const MobileCardTask = styled.div`
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
color: var(--text-secondary);
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const MobileFocusSection = styled.div`
|
|
|
|
|
border: 1px solid var(--border-color);
|
|
|
|
|
background: var(--bg-surface);
|
|
|
|
|
padding: var(--space-md) var(--space-lg);
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: var(--space-sm);
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const MobileSectionEyebrow = styled.div`
|
|
|
|
|
font-family: var(--font-mono);
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
color: var(--text-secondary);
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
letter-spacing: 0.08em;
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const MobileFocusTitle = styled.div`
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
color: var(--text-primary);
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const MobileFocusDetail = styled.div`
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: var(--text-secondary);
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const MobileHealthRow = styled.div`
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: var(--space-sm);
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const MobileHealthChip = styled.div<{ $ok: boolean }>`
|
|
|
|
|
font-family: var(--font-mono);
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
letter-spacing: 0.06em;
|
|
|
|
|
padding: 2px 8px;
|
|
|
|
|
border: 1px solid ${({ $ok }) => ($ok ? '#00BFA5' : '#555')};
|
|
|
|
|
color: ${({ $ok }) => ($ok ? '#00BFA5' : '#777')};
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const MobileQuickActions = styled.div`
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: var(--space-sm);
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const MobileActionBtn = 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);
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
border-color: var(--border-hover);
|
|
|
|
|
color: var(--text-primary);
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const MobileContextInline = styled.div`
|
|
|
|
|
border: 1px solid var(--border-color);
|
|
|
|
|
background: var(--bg-surface);
|
|
|
|
|
padding: var(--space-md) var(--space-lg);
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: var(--space-md);
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const MobileContextHeader = styled.div`
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const MobileContextTitle = styled.div`
|
|
|
|
|
font-family: var(--font-mono);
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
color: var(--text-secondary);
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
letter-spacing: 0.08em;
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const MobileCloseBtn = 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 MobileSelectedSummary = styled.div`
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: var(--space-sm);
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const MobileSelectedMeta = styled.div`
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 2px;
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const MobileSelectedName = styled.div`
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
color: var(--text-primary);
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const MobileSelectedRole = styled.div`
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
color: var(--text-secondary);
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const MobileTaskBox = 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 MobileChatArea = styled.div`
|
|
|
|
|
height: 400px;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
|
|
|
|
@media (max-width: 389px) {
|
|
|
|
|
height: 360px;
|
|
|
|
|
}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
export default function OfficePage() {
|
|
|
|
|
const [sisters, setSisters] = useState<SisterApiItem[]>([]);
|
|
|
|
|
const [opsData, setOpsData] = useState<DashboardOpsData | null>(null);
|
|
|
|
|
@@ -437,6 +670,23 @@ export default function OfficePage() {
|
|
|
|
|
qaDocLatestAt: null,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const selectedSisterNode = useMemo(() => {
|
|
|
|
|
if (!selected) return null;
|
|
|
|
|
if (selected.type === 'sister') return sisterNodes.find((s) => s.name === selected.name) ?? null;
|
|
|
|
|
if (selected.type === 'subagent') return sisterNodes.find((s) => s.name === selected.sister) ?? null;
|
|
|
|
|
return null;
|
|
|
|
|
}, [selected, sisterNodes]);
|
|
|
|
|
|
|
|
|
|
const selectedSubagent = useMemo(() => {
|
|
|
|
|
if (selected?.type !== 'subagent') return null;
|
|
|
|
|
return selectedSisterNode?.subagents.find((sub) => sub.id === selected.id) ?? null;
|
|
|
|
|
}, [selected, selectedSisterNode]);
|
|
|
|
|
|
|
|
|
|
const onlineCount = useMemo(
|
|
|
|
|
() => effectiveSisters.filter((s) => s.status === 'online' || s.status === 'working').length,
|
|
|
|
|
[effectiveSisters],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
return <LoadingOverlay>오피스 데이터 로딩 중...</LoadingOverlay>;
|
|
|
|
|
}
|
|
|
|
|
@@ -457,7 +707,8 @@ export default function OfficePage() {
|
|
|
|
|
</FreshnessBar>
|
|
|
|
|
</PageHeader>
|
|
|
|
|
|
|
|
|
|
<MainArea>
|
|
|
|
|
{/* ─── Desktop layout (≥768px) ─── */}
|
|
|
|
|
<DesktopMain>
|
|
|
|
|
<SceneColumn>
|
|
|
|
|
<OfficeScene
|
|
|
|
|
sisters={sisterNodes}
|
|
|
|
|
@@ -481,8 +732,115 @@ export default function OfficePage() {
|
|
|
|
|
onOpenChat={handleOpenChat}
|
|
|
|
|
onClear={() => setSelected(null)}
|
|
|
|
|
/>
|
|
|
|
|
</MainArea>
|
|
|
|
|
</DesktopMain>
|
|
|
|
|
|
|
|
|
|
{/* ─── Mobile layout (<768px) ─── */}
|
|
|
|
|
<MobileFlow>
|
|
|
|
|
{/* 1. Sister status cards */}
|
|
|
|
|
<MobileSisterGrid>
|
|
|
|
|
{sisterNodes.map((sister) => {
|
|
|
|
|
const isSelected = selected?.type === 'sister' && selected.name === sister.name;
|
|
|
|
|
return (
|
|
|
|
|
<MobileSisterCard
|
|
|
|
|
key={sister.name}
|
|
|
|
|
$state={sister.state}
|
|
|
|
|
$selected={isSelected}
|
|
|
|
|
onClick={() => handleSelectSister(sister.name)}
|
|
|
|
|
>
|
|
|
|
|
<SisterAvatar name={sister.name} size={32} />
|
|
|
|
|
<MobileCardMeta>
|
|
|
|
|
<MobileCardName>{sister.displayName}</MobileCardName>
|
|
|
|
|
<MobileCardState $state={sister.state}>{sister.state}</MobileCardState>
|
|
|
|
|
{sister.currentTask && <MobileCardTask>{sister.currentTask}</MobileCardTask>}
|
|
|
|
|
</MobileCardMeta>
|
|
|
|
|
</MobileSisterCard>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</MobileSisterGrid>
|
|
|
|
|
|
|
|
|
|
{/* 2. Current focus + health summary */}
|
|
|
|
|
<MobileFocusSection>
|
|
|
|
|
<MobileSectionEyebrow>current focus</MobileSectionEyebrow>
|
|
|
|
|
<MobileFocusTitle>{pipeline.activeTask}</MobileFocusTitle>
|
|
|
|
|
<MobileFocusDetail>{pipeline.focus}</MobileFocusDetail>
|
|
|
|
|
<MobileHealthRow>
|
|
|
|
|
<MobileHealthChip $ok={sisterDataMode === 'live'}>
|
|
|
|
|
ws: {sisterDataMode === 'live' ? 'connected' : 'disconnected'}
|
|
|
|
|
</MobileHealthChip>
|
|
|
|
|
<MobileHealthChip $ok={onlineCount >= 3}>
|
|
|
|
|
{onlineCount}/{SISTER_ORDER.length} online
|
|
|
|
|
</MobileHealthChip>
|
|
|
|
|
<MobileHealthChip $ok={pipeline.deployState !== 'blocked'}>
|
|
|
|
|
deploy: {pipeline.deployState}
|
|
|
|
|
</MobileHealthChip>
|
|
|
|
|
</MobileHealthRow>
|
|
|
|
|
</MobileFocusSection>
|
|
|
|
|
|
|
|
|
|
{/* 3. Quick actions */}
|
|
|
|
|
<MobileQuickActions>
|
|
|
|
|
{SISTER_ORDER.map((name) => (
|
|
|
|
|
<MobileActionBtn key={name} onClick={() => handleOpenChat(name)}>
|
|
|
|
|
{SISTER_DISPLAY[name]} 채팅
|
|
|
|
|
</MobileActionBtn>
|
|
|
|
|
))}
|
|
|
|
|
</MobileQuickActions>
|
|
|
|
|
|
|
|
|
|
{/* 4. Inline context (selected sister detail) */}
|
|
|
|
|
{selected && selectedSisterNode && (
|
|
|
|
|
<MobileContextInline>
|
|
|
|
|
<MobileContextHeader>
|
|
|
|
|
<MobileContextTitle>
|
|
|
|
|
{selected.type === 'sister' ? selectedSisterNode.displayName : selectedSubagent?.label ?? ''} · context
|
|
|
|
|
</MobileContextTitle>
|
|
|
|
|
<MobileCloseBtn onClick={() => setSelected(null)}>✕</MobileCloseBtn>
|
|
|
|
|
</MobileContextHeader>
|
|
|
|
|
<MobileSelectedSummary>
|
|
|
|
|
<SisterAvatar name={selectedSisterNode.name} size={28} />
|
|
|
|
|
<MobileSelectedMeta>
|
|
|
|
|
<MobileSelectedName>
|
|
|
|
|
{selected.type === 'sister' ? selectedSisterNode.displayName : selectedSubagent?.label}
|
|
|
|
|
</MobileSelectedName>
|
|
|
|
|
<MobileSelectedRole>{selectedSisterNode.role}</MobileSelectedRole>
|
|
|
|
|
</MobileSelectedMeta>
|
|
|
|
|
</MobileSelectedSummary>
|
|
|
|
|
{(selectedSisterNode.currentTask || selectedSisterNode.activeSessionLabel) && (
|
|
|
|
|
<MobileTaskBox>
|
|
|
|
|
{selectedSisterNode.currentTask ?? selectedSisterNode.activeSessionLabel}
|
|
|
|
|
</MobileTaskBox>
|
|
|
|
|
)}
|
|
|
|
|
<MobileQuickActions>
|
|
|
|
|
<MobileActionBtn onClick={() => handleOpenChat(selectedSisterNode.name)}>채팅</MobileActionBtn>
|
|
|
|
|
</MobileQuickActions>
|
|
|
|
|
</MobileContextInline>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 5. Chat (if open) */}
|
|
|
|
|
{chatSister && (
|
|
|
|
|
<MobileChatArea>
|
|
|
|
|
<ChatWorkspace initialSister={chatSister} onClose={() => setChatSister(null)} />
|
|
|
|
|
</MobileChatArea>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 6. Pipeline panel */}
|
|
|
|
|
<PipelinePanel
|
|
|
|
|
activeTask={pipeline.activeTask}
|
|
|
|
|
focus={pipeline.focus}
|
|
|
|
|
reviewLoopCount={pipeline.reviewLoopCount}
|
|
|
|
|
escalationCount={pipeline.escalationCount}
|
|
|
|
|
deployState={pipeline.deployState}
|
|
|
|
|
nodes={pipeline.nodes}
|
|
|
|
|
freshness={`generated ${formatGeneratedAt(freshness.generatedAt)}`}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* 7. Server health */}
|
|
|
|
|
<ServerHealthPanel
|
|
|
|
|
servers={serverEntries}
|
|
|
|
|
dataMode={sisterDataMode}
|
|
|
|
|
generatedAt={freshness.generatedAt}
|
|
|
|
|
/>
|
|
|
|
|
</MobileFlow>
|
|
|
|
|
|
|
|
|
|
{/* ─── Desktop bottom panels ─── */}
|
|
|
|
|
<BottomPanels>
|
|
|
|
|
<PipelinePanel
|
|
|
|
|
activeTask={pipeline.activeTask}
|
|
|
|
|
|