fix: stabilize office dashboard demo states and motion

This commit is contained in:
2026-04-07 12:12:11 +09:00
parent 6e0583efeb
commit a731ecee4b
3 changed files with 168 additions and 61 deletions

View File

@@ -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>

View File

@@ -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

View File

@@ -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>