fix: stabilize office dashboard demo states and motion
This commit is contained in:
@@ -100,6 +100,33 @@ const SISTER_ROLES: Record<SisterName, string> = {
|
||||
|
||||
const SISTER_ORDER: SisterName[] = ['harang', 'narang', 'darang', 'erang'];
|
||||
|
||||
const FALLBACK_SISTERS: SisterApiItem[] = [
|
||||
{
|
||||
name: 'harang',
|
||||
status: 'working',
|
||||
role: SISTER_ROLES.harang,
|
||||
currentTask: 'SPRINT-016 scope lock',
|
||||
},
|
||||
{
|
||||
name: 'narang',
|
||||
status: 'working',
|
||||
role: SISTER_ROLES.narang,
|
||||
currentTask: 'Office scene dynamic movement',
|
||||
},
|
||||
{
|
||||
name: 'darang',
|
||||
status: 'online',
|
||||
role: SISTER_ROLES.darang,
|
||||
currentTask: 'Review loop standby',
|
||||
},
|
||||
{
|
||||
name: 'erang',
|
||||
status: 'online',
|
||||
role: SISTER_ROLES.erang,
|
||||
currentTask: 'Gateway health watch',
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function statusToAgentState(status: SisterApiItem['status'], hasTask: boolean): AgentState {
|
||||
@@ -143,7 +170,7 @@ function buildSisterNodes(sisters: SisterApiItem[]): SisterNode[] {
|
||||
});
|
||||
}
|
||||
|
||||
function buildServerEntries(sisters: SisterApiItem[]): ServerEntry[] {
|
||||
function buildServerEntries(sisters: SisterApiItem[], sisterSource: 'live' | 'snapshot' | 'fallback'): ServerEntry[] {
|
||||
const sisterEntries: ServerEntry[] = SISTER_ORDER.map((name) => {
|
||||
const s = sisters.find((x) => x.name === name);
|
||||
return {
|
||||
@@ -152,7 +179,7 @@ function buildServerEntries(sisters: SisterApiItem[]): ServerEntry[] {
|
||||
type: 'sister' as const,
|
||||
status: s?.status ?? 'unknown',
|
||||
detail: s?.currentTask ?? null,
|
||||
source: s ? 'snapshot' : 'fallback',
|
||||
source: s ? sisterSource : 'fallback',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -337,8 +364,16 @@ export default function OfficePage() {
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const sisterNodes = buildSisterNodes(sisters);
|
||||
const serverEntries = buildServerEntries(sisters);
|
||||
const hasSisterSnapshot = sisters.length > 0;
|
||||
const sisterDataMode: 'live' | 'snapshot' | 'fallback' = connected
|
||||
? 'live'
|
||||
: hasSisterSnapshot
|
||||
? 'snapshot'
|
||||
: 'fallback';
|
||||
const effectiveSisters = hasSisterSnapshot ? sisters : FALLBACK_SISTERS;
|
||||
|
||||
const sisterNodes = buildSisterNodes(effectiveSisters);
|
||||
const serverEntries = buildServerEntries(effectiveSisters, sisterDataMode);
|
||||
|
||||
const handleSelectSister = useCallback((name: SisterName) => {
|
||||
setSelected({ type: 'sister', name });
|
||||
@@ -388,8 +423,8 @@ export default function OfficePage() {
|
||||
<PageTitle>🏢 오피스 대시보드</PageTitle>
|
||||
<PageMeta>4자매 · 17 서브에이전트 · 협업 관제</PageMeta>
|
||||
<FreshnessBar>
|
||||
<FreshnessBadge $type={connected ? 'live' : 'snapshot'}>
|
||||
자매: {connected ? 'live · ws' : 'snapshot · poll'}
|
||||
<FreshnessBadge $type={sisterDataMode}>
|
||||
자매: {sisterDataMode === 'live' ? 'live · ws' : sisterDataMode === 'snapshot' ? 'snapshot · poll' : 'fallback · doc'}
|
||||
</FreshnessBadge>
|
||||
<FreshnessBadge $type="fallback">
|
||||
서브에이전트: fallback
|
||||
@@ -407,7 +442,7 @@ export default function OfficePage() {
|
||||
selected={selected}
|
||||
onSelectSister={handleSelectSister}
|
||||
onSelectSubagent={handleSelectSubagent}
|
||||
connectedViaWs={connected}
|
||||
dataMode={sisterDataMode}
|
||||
/>
|
||||
|
||||
{chatSister && (
|
||||
@@ -440,7 +475,7 @@ export default function OfficePage() {
|
||||
/>
|
||||
<ServerHealthPanel
|
||||
servers={serverEntries}
|
||||
connectedViaWs={connected}
|
||||
dataMode={sisterDataMode}
|
||||
generatedAt={freshness.generatedAt}
|
||||
/>
|
||||
</BottomPanels>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import styled, { keyframes } from 'styled-components';
|
||||
import styled from 'styled-components';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -35,27 +35,9 @@ interface OfficeSceneProps {
|
||||
selected: SelectedAgent | null;
|
||||
onSelectSister: (name: SisterName) => void;
|
||||
onSelectSubagent: (id: string, sister: SisterName) => void;
|
||||
connectedViaWs: boolean;
|
||||
dataMode: 'live' | 'snapshot' | 'fallback';
|
||||
}
|
||||
|
||||
// ─── Animations ──────────────────────────────────────────────────────────────
|
||||
|
||||
const pulse = keyframes`
|
||||
0% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
100%{ opacity: 0.4; }
|
||||
`;
|
||||
|
||||
const blink = keyframes`
|
||||
0%, 100% { stroke-opacity: 1; }
|
||||
50% { stroke-opacity: 0.2; }
|
||||
`;
|
||||
|
||||
const flowAnim = keyframes`
|
||||
0% { stroke-dashoffset: 40; }
|
||||
100% { stroke-dashoffset: 0; }
|
||||
`;
|
||||
|
||||
// ─── Styled wrappers ──────────────────────────────────────────────────────────
|
||||
|
||||
const SceneWrapper = styled.div`
|
||||
@@ -95,6 +77,7 @@ const WsDot = styled.span<{ $connected: boolean }>`
|
||||
|
||||
const VB_W = 800;
|
||||
const VB_H = 460;
|
||||
type Point = [number, number];
|
||||
|
||||
// Zone rects [x, y, w, h]
|
||||
const ZONES = {
|
||||
@@ -116,13 +99,20 @@ const SISTER_POS: Record<SisterName, [number, number]> = {
|
||||
const CONF = { cx: 400, cy: 232, rx: 55, ry: 30 };
|
||||
|
||||
// Subagent grid positions (cx, cy) per sister
|
||||
const SUBAGENT_POSITIONS: Record<SisterName, [number, number][]> = {
|
||||
const SUBAGENT_POSITIONS: Record<SisterName, Point[]> = {
|
||||
harang: [[175, 65], [270, 65], [220, 160]],
|
||||
darang: [[460, 65], [560, 65], [460, 160], [560, 160]],
|
||||
narang: [[175, 295], [270, 295], [175, 395], [270, 395]],
|
||||
erang: [[460, 295], [555, 295], [650, 295], [460, 395], [555, 395]],
|
||||
};
|
||||
|
||||
const SUBAGENT_TARGETS: Record<SisterName, Point[]> = {
|
||||
harang: [[295, 112], [338, 148], [365, 190]],
|
||||
darang: [[505, 112], [462, 148], [438, 188], [520, 196]],
|
||||
narang: [[292, 332], [330, 302], [362, 276], [344, 374]],
|
||||
erang: [[618, 322], [664, 298], [702, 286], [610, 388], [664, 380]],
|
||||
};
|
||||
|
||||
// Zone accent colors
|
||||
const ZONE_COLORS: Record<SisterName, string> = {
|
||||
harang: 'rgba(41, 121, 255, 0.06)',
|
||||
@@ -147,6 +137,45 @@ const STATE_COLORS: Record<AgentState, string> = {
|
||||
error: '#FF1744',
|
||||
};
|
||||
|
||||
const STATE_PROGRESS: Record<AgentState, number> = {
|
||||
idle: 0,
|
||||
error: 0,
|
||||
thinking: 0.48,
|
||||
tool_calling: 0.88,
|
||||
speaking: 0.66,
|
||||
};
|
||||
|
||||
function lerpPoint(from: Point, to: Point, progress: number): Point {
|
||||
return [
|
||||
from[0] + (to[0] - from[0]) * progress,
|
||||
from[1] + (to[1] - from[1]) * progress,
|
||||
];
|
||||
}
|
||||
|
||||
function getSubagentPosition(sister: SisterName, index: number, state: AgentState): Point {
|
||||
const from = SUBAGENT_POSITIONS[sister][index] ?? SUBAGENT_POSITIONS[sister][0];
|
||||
const to = SUBAGENT_TARGETS[sister][index] ?? from;
|
||||
return lerpPoint(from, to, STATE_PROGRESS[state]);
|
||||
}
|
||||
|
||||
function getSubagentMotion(state: AgentState, index: number): { dx: number; dy: number; dur: string } | null {
|
||||
const phase = index % 2 === 0 ? 1 : -1;
|
||||
|
||||
if (state === 'thinking') {
|
||||
return { dx: 5 * phase, dy: -6, dur: '3.6s' };
|
||||
}
|
||||
|
||||
if (state === 'tool_calling') {
|
||||
return { dx: 10 * phase, dy: -14, dur: '2s' };
|
||||
}
|
||||
|
||||
if (state === 'speaking') {
|
||||
return { dx: 6 * phase, dy: -4, dur: '2.8s' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Helper: AgentCircle (rendered as SVG group) ──────────────────────────────
|
||||
|
||||
function SisterCircle({
|
||||
@@ -177,14 +206,24 @@ function SisterCircle({
|
||||
stroke={color}
|
||||
strokeWidth={state === 'error' ? 2.5 : 1.5}
|
||||
opacity={state === 'idle' ? 0.4 : 1}
|
||||
style={
|
||||
state === 'thinking'
|
||||
? { animation: `${pulse} 1.8s ease-in-out infinite` }
|
||||
: state === 'tool_calling'
|
||||
? { animation: `${blink} 0.9s ease-in-out infinite` }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
>
|
||||
{state === 'thinking' && (
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.45;1;0.45"
|
||||
dur="1.8s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
)}
|
||||
{state === 'tool_calling' && (
|
||||
<animate
|
||||
attributeName="stroke-opacity"
|
||||
values="1;0.2;1"
|
||||
dur="0.9s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
)}
|
||||
</circle>
|
||||
{/* Body */}
|
||||
<circle cx={cx} cy={cy} r={r} fill="#1e1e1e" stroke={color} strokeWidth={1} />
|
||||
{/* Emoji */}
|
||||
@@ -212,15 +251,27 @@ function SisterCircle({
|
||||
}
|
||||
|
||||
function SubagentCircle({
|
||||
cx, cy, r, state, label, selected, onClick,
|
||||
cx, cy, r, state, label, selected, motion, motionBegin, onClick,
|
||||
}: {
|
||||
cx: number; cy: number; r: number;
|
||||
state: AgentState; label: string; selected: boolean;
|
||||
motion: { dx: number; dy: number; dur: string } | null;
|
||||
motionBegin: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const color = STATE_COLORS[state];
|
||||
return (
|
||||
<g onClick={onClick} style={{ cursor: 'pointer' }}>
|
||||
{motion && (
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="translate"
|
||||
values={`0 0; ${motion.dx} ${motion.dy}; 0 0`}
|
||||
dur={motion.dur}
|
||||
begin={motionBegin}
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
)}
|
||||
{selected && (
|
||||
<circle cx={cx} cy={cy} r={r + 5} fill="none" stroke="var(--text-primary)" strokeWidth={1} opacity={0.5} />
|
||||
)}
|
||||
@@ -228,8 +279,16 @@ function SubagentCircle({
|
||||
cx={cx} cy={cy} r={r + 2}
|
||||
fill="none" stroke={color} strokeWidth={1}
|
||||
opacity={state === 'idle' ? 0.3 : 0.8}
|
||||
style={state === 'thinking' ? { animation: `${pulse} 2s ease-in-out infinite` } : undefined}
|
||||
/>
|
||||
>
|
||||
{state === 'thinking' && (
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.35;0.95;0.35"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
)}
|
||||
</circle>
|
||||
<circle cx={cx} cy={cy} r={r} fill="#1a1a1a" stroke={color} strokeWidth={0.8} />
|
||||
<text
|
||||
x={cx} y={cy + 4}
|
||||
@@ -257,12 +316,17 @@ function ConnectorLine({
|
||||
stroke={active ? 'rgba(245,245,245,0.35)' : 'rgba(255,255,255,0.08)'}
|
||||
strokeWidth={active ? 1.5 : 1}
|
||||
strokeDasharray={active ? '6 4' : '3 4'}
|
||||
style={
|
||||
active
|
||||
? { animation: `${flowAnim} 1.4s linear infinite` }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
strokeDashoffset={active ? 40 : 0}
|
||||
>
|
||||
{active && (
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
values="40;0"
|
||||
dur="1.4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
)}
|
||||
</line>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -278,8 +342,17 @@ function PathConnector({
|
||||
stroke={active ? 'rgba(245,245,245,0.3)' : 'rgba(255,255,255,0.06)'}
|
||||
strokeWidth={active ? 1.5 : 1}
|
||||
strokeDasharray={active ? '6 4' : '3 4'}
|
||||
style={active ? { animation: `${flowAnim} 1.8s linear infinite` } : undefined}
|
||||
/>
|
||||
strokeDashoffset={active ? 40 : 0}
|
||||
>
|
||||
{active && (
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
values="40;0"
|
||||
dur="1.8s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
)}
|
||||
</path>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -299,7 +372,7 @@ export default function OfficeScene({
|
||||
selected,
|
||||
onSelectSister,
|
||||
onSelectSubagent,
|
||||
connectedViaWs,
|
||||
dataMode,
|
||||
}: OfficeSceneProps) {
|
||||
const getSister = useCallback(
|
||||
(name: SisterName) => sisters.find((s) => s.name === name),
|
||||
@@ -329,8 +402,8 @@ export default function OfficeScene({
|
||||
return (
|
||||
<SceneWrapper>
|
||||
<FreshnessLabel>
|
||||
<WsDot $connected={connectedViaWs} />
|
||||
{connectedViaWs ? 'live · ws' : 'snapshot · poll'}
|
||||
<WsDot $connected={dataMode === 'live'} />
|
||||
{dataMode === 'live' ? 'live · ws' : dataMode === 'snapshot' ? 'snapshot · poll' : 'fallback · doc-derived'}
|
||||
{' · subagents: fallback'}
|
||||
</FreshnessLabel>
|
||||
|
||||
@@ -456,10 +529,9 @@ export default function OfficeScene({
|
||||
{SISTER_ORDER.map((sisterName) => {
|
||||
const sisterData = getSister(sisterName);
|
||||
const subagents = sisterData?.subagents ?? [];
|
||||
const positions = SUBAGENT_POSITIONS[sisterName];
|
||||
return subagents.map((sub, i) => {
|
||||
const pos = positions[i];
|
||||
if (!pos) return null;
|
||||
const pos = getSubagentPosition(sisterName, i, sub.state);
|
||||
const motion = getSubagentMotion(sub.state, i);
|
||||
return (
|
||||
<SubagentCircle
|
||||
key={sub.id}
|
||||
@@ -467,6 +539,8 @@ export default function OfficeScene({
|
||||
state={sub.state}
|
||||
label={sub.name}
|
||||
selected={isSubSelected(sub.id)}
|
||||
motion={motion}
|
||||
motionBegin={`${i * 0.22}s`}
|
||||
onClick={() => onSelectSubagent(sub.id, sisterName)}
|
||||
/>
|
||||
);
|
||||
@@ -477,11 +551,9 @@ export default function OfficeScene({
|
||||
{SISTER_ORDER.map((sisterName) => {
|
||||
const sisterData = getSister(sisterName);
|
||||
const subagents = sisterData?.subagents ?? [];
|
||||
const positions = SUBAGENT_POSITIONS[sisterName];
|
||||
const [sx, sy] = SISTER_POS[sisterName];
|
||||
return subagents.map((sub, i) => {
|
||||
const pos = positions[i];
|
||||
if (!pos) return null;
|
||||
const pos = getSubagentPosition(sisterName, i, sub.state);
|
||||
const active = sub.state !== 'idle' && sub.state !== 'error';
|
||||
return (
|
||||
<line
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface ServerEntry {
|
||||
|
||||
interface ServerHealthPanelProps {
|
||||
servers: ServerEntry[];
|
||||
connectedViaWs: boolean;
|
||||
dataMode: 'live' | 'snapshot' | 'fallback';
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ function formatGeneratedAt(ts: string): string {
|
||||
|
||||
export default function ServerHealthPanel({
|
||||
servers,
|
||||
connectedViaWs,
|
||||
dataMode,
|
||||
generatedAt,
|
||||
}: ServerHealthPanelProps) {
|
||||
const onlineCount = servers.filter((s) => s.status === 'online' || s.status === 'working').length;
|
||||
@@ -160,8 +160,8 @@ export default function ServerHealthPanel({
|
||||
<Panel>
|
||||
<PanelHeader>
|
||||
<Eyebrow>
|
||||
<WsDot $connected={connectedViaWs} />
|
||||
server health · {connectedViaWs ? 'live' : 'snapshot'}
|
||||
<WsDot $connected={dataMode === 'live'} />
|
||||
server health · {dataMode}
|
||||
{' · '}{onlineCount}/{servers.length} online
|
||||
</Eyebrow>
|
||||
<FreshnessNote>refreshed {formatGeneratedAt(generatedAt)}</FreshnessNote>
|
||||
|
||||
Reference in New Issue
Block a user