Files
hanarang-dashboard/frontend/components/office/OfficeScene.tsx
나랑이 54535e7b5e fix: resolve 8 review/security issues (SPRINT-016 hotfix)
Security:
- JwtGuard + RoleGuard on all sisters endpoints
- Admin-only access for config/sessions/subagents/activity
- ThrottlerGuard on /auth/refresh
- HttpOnly SameSite cookies + CSRF (replaces localStorage)

Code Quality:
- Per-sister draft input (Record<SisterName, string>)
- crypto.randomUUID for optimistic message ids (dedupe ready)
- Polling disabled while WebSocket connected
- SVG keyboard accessibility (role/tabIndex/onKeyDown)
2026-04-09 08:51:28 +09:00

497 lines
15 KiB
TypeScript

'use client';
import React, { useCallback } from 'react';
import styled from 'styled-components';
import { API_URL } from '@/lib/config';
export type AgentState = 'idle' | 'thinking' | 'tool_calling' | 'speaking' | 'error';
export type SisterName = 'harang' | 'narang' | 'darang' | 'erang';
export interface SubAgent {
id: string;
name: string;
label: string;
sister: SisterName;
state: AgentState;
currentTask?: string | null;
updatedAt?: number | null;
sessionLabel?: string | null;
}
export interface SisterNode {
name: SisterName;
displayName: string;
role: string;
state: AgentState;
currentTask: string | null;
activeSessionLabel?: string | null;
subagents: SubAgent[];
}
export type SelectedAgent =
| { type: 'sister'; name: SisterName }
| { type: 'subagent'; id: string; sister: SisterName };
interface OfficeSceneProps {
sisters: SisterNode[];
selected: SelectedAgent | null;
onSelectSister: (name: SisterName) => void;
onSelectSubagent: (id: string, sister: SisterName) => void;
dataMode: 'live' | 'snapshot' | 'fallback';
subagentMode: 'live' | 'fallback';
}
const SceneWrapper = styled.div`
position: relative;
width: 100%;
aspect-ratio: 800 / 460;
max-height: 60vh;
border: 1px solid var(--border-color);
background: var(--bg-surface);
overflow: hidden;
user-select: none;
`;
const FreshnessLabel = styled.div`
position: absolute;
top: 8px;
right: 12px;
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
pointer-events: none;
`;
const WsDot = styled.span<{ $connected: boolean }>`
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: ${({ $connected }) => ($connected ? '#00FF00' : '#666')};
margin-right: 5px;
vertical-align: middle;
`;
const VB_W = 800;
const VB_H = 460;
type Point = [number, number];
const ZONES = {
harang: [5, 5, 345, 200] as const,
darang: [450, 5, 345, 200] as const,
narang: [5, 260, 345, 195] as const,
erang: [450, 260, 345, 195] as const,
};
const SISTER_POS: Record<SisterName, [number, number]> = {
harang: [75, 105],
darang: [725, 105],
narang: [75, 358],
erang: [725, 358],
};
const CONF = { cx: 400, cy: 232, rx: 55, ry: 30 };
const SUBAGENT_POSITIONS: Record<SisterName, Point[]> = {
harang: [[175, 65], [270, 65], [220, 160]],
darang: [[460, 65], [560, 65], [660, 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, 94], [560, 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]],
};
const ZONE_COLORS: Record<SisterName, string> = {
harang: 'rgba(41, 121, 255, 0.06)',
narang: 'rgba(0, 191, 165, 0.06)',
darang: 'rgba(255, 64, 129, 0.06)',
erang: 'rgba(255, 109, 0, 0.06)',
};
const ZONE_BORDER: Record<SisterName, string> = {
harang: 'rgba(41, 121, 255, 0.25)',
narang: 'rgba(0, 191, 165, 0.25)',
darang: 'rgba(255, 64, 129, 0.25)',
erang: 'rgba(255, 109, 0, 0.25)',
};
const STATE_COLORS: Record<AgentState, string> = {
idle: '#444444',
thinking: '#2979FF',
tool_calling: '#FF9800',
speaking: '#00BFA5',
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;
}
function SisterCircle({
cx,
cy,
r,
state,
name,
selected,
onClick,
}: {
cx: number;
cy: number;
r: number;
state: AgentState;
name: SisterName;
selected: boolean;
onClick: () => void;
}) {
const color = STATE_COLORS[state];
const clipId = `avatar-clip-${name}`;
return (
<g
role="button"
tabIndex={0}
aria-label={`${name} — state: ${state}`}
onClick={onClick}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(); } }}
style={{ cursor: 'pointer', outline: 'none' }}
>
{selected && (
<circle
cx={cx}
cy={cy}
r={r + 8}
fill="none"
stroke="var(--text-primary)"
strokeWidth={1}
strokeDasharray="4 3"
opacity={0.6}
/>
)}
<circle
cx={cx}
cy={cy}
r={r + 4}
fill="none"
stroke={color}
strokeWidth={state === 'error' ? 2.5 : 1.5}
opacity={state === 'idle' ? 0.4 : 1}
>
{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>
<circle cx={cx} cy={cy} r={r} fill="#1e1e1e" stroke={color} strokeWidth={1} />
<defs>
<clipPath id={clipId}>
<circle cx={cx} cy={cy} r={r - 2} />
</clipPath>
</defs>
<image
href={`${API_URL}/api/sisters/${name}/avatar`}
x={cx - r + 2}
y={cy - r + 2}
width={(r - 2) * 2}
height={(r - 2) * 2}
preserveAspectRatio="xMidYMid slice"
clipPath={`url(#${clipId})`}
/>
<text
x={cx}
y={cy + r + 16}
textAnchor="middle"
fontSize={9}
fontFamily="var(--font-mono)"
fill={color}
style={{ textTransform: 'uppercase', letterSpacing: '0.06em', userSelect: 'none', pointerEvents: 'none' }}
>
{state}
</text>
</g>
);
}
function SubagentCircle({
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
role="button"
tabIndex={0}
aria-label={`subagent ${label} — state: ${state}`}
onClick={onClick}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(); } }}
style={{ cursor: 'pointer', outline: 'none' }}
>
{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} />
)}
<circle cx={cx} cy={cy} r={r + 2} fill="none" stroke={color} strokeWidth={1} opacity={state === 'idle' ? 0.3 : 0.8}>
{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}
textAnchor="middle"
fontSize={8}
fontFamily="var(--font-mono)"
fill={color}
opacity={0.9}
style={{ userSelect: 'none', pointerEvents: 'none' }}
>
{label.length > 8 ? `${label.slice(0, 7)}` : label}
</text>
</g>
);
}
function ConnectorLine({ x1, y1, x2, y2, active }: { x1: number; y1: number; x2: number; y2: number; active: boolean }) {
return (
<line
x1={x1}
y1={y1}
x2={x2}
y2={y2}
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'}
strokeDashoffset={active ? 40 : 0}
>
{active && <animate attributeName="stroke-dashoffset" values="40;0" dur="1.4s" repeatCount="indefinite" />}
</line>
);
}
function PathConnector({ d, active }: { d: string; active: boolean }) {
return (
<path
d={d}
fill="none"
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'}
strokeDashoffset={active ? 40 : 0}
>
{active && <animate attributeName="stroke-dashoffset" values="40;0" dur="1.8s" repeatCount="indefinite" />}
</path>
);
}
const SISTER_ORDER: SisterName[] = ['harang', 'narang', 'darang', 'erang'];
export default function OfficeScene({
sisters,
selected,
onSelectSister,
onSelectSubagent,
dataMode,
subagentMode,
}: OfficeSceneProps) {
const getSister = useCallback(
(name: SisterName) => sisters.find((s) => s.name === name),
[sisters],
);
const isActive = useCallback(
(name: SisterName) => {
const sister = getSister(name);
return sister?.state === 'thinking' || sister?.state === 'tool_calling' || sister?.state === 'speaking';
},
[getSister],
);
const isSisterSelected = (name: SisterName) => selected?.type === 'sister' && selected.name === name;
const isSubSelected = (id: string) => selected?.type === 'subagent' && selected.id === id;
const harangActive = isActive('harang');
const narangActive = isActive('narang');
const darangActive = isActive('darang');
const erangActive = isActive('erang');
return (
<SceneWrapper>
<FreshnessLabel>
<WsDot $connected={dataMode === 'live'} />
{dataMode === 'live' ? 'live · ws' : dataMode === 'snapshot' ? 'snapshot · poll' : 'fallback · doc-derived'}
{subagentMode === 'live' ? ' · subagents: live · runtime' : ' · subagents: fallback'}
</FreshnessLabel>
<svg viewBox={`0 0 ${VB_W} ${VB_H}`} width="100%" height="100%" style={{ display: 'block' }}>
{SISTER_ORDER.map((name) => {
const [zx, zy, zw, zh] = ZONES[name];
return (
<rect
key={`zone-${name}`}
x={zx}
y={zy}
width={zw}
height={zh}
rx={4}
fill={ZONE_COLORS[name]}
stroke={ZONE_BORDER[name]}
strokeWidth={1}
/>
);
})}
<rect x={358} y={5} width={84} height={450} rx={2} fill="#111111" stroke="rgba(255,255,255,0.04)" strokeWidth={1} />
<rect x={5} y={205} width={790} height={50} rx={2} fill="#111111" stroke="rgba(255,255,255,0.04)" strokeWidth={1} />
<ellipse cx={CONF.cx} cy={CONF.cy} rx={CONF.rx} ry={CONF.ry} fill="#1a1a1a" stroke="rgba(255,255,255,0.15)" strokeWidth={1} />
<text x={CONF.cx} y={CONF.cy + 4} textAnchor="middle" fontSize={9} fontFamily="var(--font-mono)" fill="rgba(255,255,255,0.4)">
</text>
<ConnectorLine x1={SISTER_POS.harang[0]} y1={SISTER_POS.harang[1] + 30} x2={SISTER_POS.narang[0]} y2={SISTER_POS.narang[1] - 30} active={harangActive || narangActive} />
<ConnectorLine x1={SISTER_POS.darang[0]} y1={SISTER_POS.darang[1] + 30} x2={SISTER_POS.erang[0]} y2={SISTER_POS.erang[1] - 30} active={darangActive || erangActive} />
<ConnectorLine x1={SISTER_POS.harang[0] + 30} y1={SISTER_POS.harang[1]} x2={SISTER_POS.darang[0] - 30} y2={SISTER_POS.darang[1]} active={harangActive || darangActive} />
<ConnectorLine x1={SISTER_POS.narang[0] + 30} y1={SISTER_POS.narang[1]} x2={SISTER_POS.erang[0] - 30} y2={SISTER_POS.erang[1]} active={narangActive || erangActive} />
<PathConnector d={`M${SISTER_POS.narang[0] + 20},${SISTER_POS.narang[1] - 20} Q${CONF.cx},${CONF.cy} ${SISTER_POS.darang[0] - 20},${SISTER_POS.darang[1] + 20}`} active={narangActive && darangActive} />
{([
['harang', 18, 22, '하랑이 · Planning'] as const,
['darang', 463, 22, '다랑이 · QA'] as const,
['narang', 18, 272, '나랑이 · Dev'] as const,
['erang', 463, 272, '이랑이 · Infra'] as const,
] as const).map(([name, lx, ly, text]) => (
<text key={`label-${name}`} x={lx} y={ly} fontSize={10} fontFamily="var(--font-mono)" fill={ZONE_BORDER[name]}>
{text}
</text>
))}
{SISTER_ORDER.map((name) => {
const sister = getSister(name);
const state: AgentState = sister?.state ?? 'idle';
const [cx, cy] = SISTER_POS[name];
return (
<SisterCircle
key={`sister-${name}`}
cx={cx}
cy={cy}
r={28}
state={state}
name={name}
selected={isSisterSelected(name)}
onClick={() => onSelectSister(name)}
/>
);
})}
{SISTER_ORDER.map((sisterName) => {
const sister = getSister(sisterName);
const subagents = sister?.subagents ?? [];
return subagents.map((sub, i) => {
const pos = getSubagentPosition(sisterName, i, sub.state);
const motion = getSubagentMotion(sub.state, i);
return (
<SubagentCircle
key={sub.id}
cx={pos[0]}
cy={pos[1]}
r={14}
state={sub.state}
label={sub.name}
selected={isSubSelected(sub.id)}
motion={motion}
motionBegin={`${i * 0.22}s`}
onClick={() => onSelectSubagent(sub.id, sisterName)}
/>
);
});
})}
{SISTER_ORDER.map((sisterName) => {
const sister = getSister(sisterName);
const subagents = sister?.subagents ?? [];
const [sx, sy] = SISTER_POS[sisterName];
return subagents.map((sub, i) => {
const pos = getSubagentPosition(sisterName, i, sub.state);
const active = sub.state !== 'idle' && sub.state !== 'error';
return (
<line
key={`conn-${sub.id}`}
x1={sx}
y1={sy}
x2={pos[0]}
y2={pos[1]}
stroke={active ? ZONE_BORDER[sisterName] : 'rgba(255,255,255,0.05)'}
strokeWidth={active ? 0.8 : 0.5}
strokeDasharray="2 3"
/>
);
});
})}
</svg>
</SceneWrapper>
);
}