feat: SPRINT-018 dashboard truth redesign implementation

- /office: Replace 4-box mobile decomposition with group mental model
  (Planning/Delivery/Review/Deploy blocks). Fallback seed no longer
  injects "working" state. Show operational summary on first load.
  Add truthful state labels (connected idle/active working/snapshot
  only/fallback/offline). Fix 360/390 mobile overflow.

- / dashboard: Separate socket connection from freshness badges.
  "EVENT STREAM OFF" is neutral, not error-like. Remove owner fallback
  to narang — show "NO ACTIVE OWNER" when null. Add consistent source
  labels (MIRRORED EVENT/SNAPSHOT/DOC-DERIVED) to all sections.

- /projects/[id]: Replace "ASSIGNED NODES" with truthful participant
  model. Separate CURRENT OWNERS, PARTICIPANTS (with whyVisible badges),
  and TASK ASSIGNEES into distinct sections. Fix 나랑이-only display
  by showing reason/source so it doesn't read as confirmed participation.

- /org: Complete redesign from hardcoded sister tree to company org
  chart. HQ → functional teams → members structure with status/source/
  freshness. Independent agents section. Data-driven from /api/org,
  not hardcoded harang→children tree. Mobile-friendly card layout.

- Backend sisters.service.ts: Add truth contract fields —
  connection (gateway/runtime), activity (state/label/lastActiveAt),
  working (state/confidence/reason/label), source, freshness.
  States: connected idle, active working, snapshot only, fallback,
  offline. Fallback seeds never inject "working" directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 13:00:30 +09:00
parent 92b33035b0
commit 16270e2661
5 changed files with 819 additions and 340 deletions

View File

@@ -43,6 +43,30 @@ export interface SisterRuntimeSnapshot {
subagents: RuntimeSubagent[];
}
export interface SisterConnectionState {
gateway: 'connected' | 'disconnected' | 'unknown';
runtime: 'connected' | 'disconnected' | 'unknown';
}
export interface SisterActivityState {
state: 'active' | 'idle' | 'unknown';
label: string | null;
lastActiveAt: string | null;
}
export interface SisterWorkingState {
state: 'active' | 'idle' | 'unknown';
confidence: 'confirmed' | 'inferred' | 'none';
reason: string | null;
label: string | null;
}
export interface SisterFreshness {
lastSnapshotAt: string | null;
}
export type SisterSource = 'live' | 'snapshot' | 'fallback';
export interface SisterStatus {
id: number;
name: string;
@@ -56,6 +80,11 @@ export interface SisterStatus {
activeSessionLabel: string | null;
gatewayConnected: boolean;
subagents: RuntimeSubagent[];
connection: SisterConnectionState;
activity: SisterActivityState;
working: SisterWorkingState;
source: SisterSource;
freshness: SisterFreshness;
}
interface RuntimeProbeResult {
@@ -299,6 +328,8 @@ export class SistersService {
.catch(() => {});
}
const isActive = liveState === 'thinking' || liveState === 'tool_calling' || liveState === 'speaking';
return {
id: sister.id,
name: sister.name,
@@ -312,6 +343,25 @@ export class SistersService {
activeSessionLabel: runtime.activeSessionLabel ?? null,
gatewayConnected,
subagents: runtime.subagents ?? [],
connection: {
gateway: gatewayConnected ? 'connected' : 'disconnected',
runtime: gatewayConnected ? 'connected' : 'disconnected',
},
activity: {
state: isActive ? 'active' : 'idle',
label: runtime.activeSessionLabel ?? (isActive ? 'Active session' : 'No recent operator event'),
lastActiveAt: now.toISOString(),
},
working: {
state: isActive ? 'active' : 'idle',
confidence: isActive ? 'confirmed' : 'none',
reason: isActive ? 'active_session' : null,
label: runtime.currentTask ?? null,
},
source: 'live' as SisterSource,
freshness: {
lastSnapshotAt: now.toISOString(),
},
};
} catch {
this.logger.warn(`Failed to check status for ${sister.name}`);
@@ -381,6 +431,25 @@ export class SistersService {
activeSessionLabel: null,
gatewayConnected: false,
subagents: [],
connection: {
gateway: 'disconnected',
runtime: 'disconnected',
},
activity: {
state: 'unknown',
label: null,
lastActiveAt: sister.lastSeen?.toISOString() ?? null,
},
working: {
state: 'unknown',
confidence: 'none',
reason: null,
label: null,
},
source: 'snapshot' as SisterSource,
freshness: {
lastSnapshotAt: sister.lastSeen?.toISOString() ?? null,
},
};
}

View File

@@ -128,13 +128,28 @@ const SISTER_ROLES: Record<SisterName, string> = {
const SISTER_ORDER: SisterName[] = ['harang', 'narang', 'darang', 'erang'];
const SUBAGENT_COUNT = Object.values(SUBAGENT_DEFS).reduce((total, defs) => total + defs.length, 0);
const FALLBACK_SISTERS: SisterApiItem[] = [
{ name: 'harang', status: 'working', role: SISTER_ROLES.harang, currentTask: 'runtime snapshot 확인 중' },
{ name: 'narang', status: 'working', role: SISTER_ROLES.narang, currentTask: '개발 세션 상태 동기화 중' },
{ name: 'darang', status: 'online', role: SISTER_ROLES.darang, currentTask: '검증 상태 확인 중' },
{ name: 'erang', status: 'online', role: SISTER_ROLES.erang, currentTask: '배포 런타임 확인 중' },
const GROUP_DEFS: { id: string; label: string; members: SisterName[]; description: string }[] = [
{ id: 'planning', label: 'Planning', members: ['harang'], description: 'Orchestration & Planning' },
{ id: 'delivery', label: 'Delivery', members: ['narang'], description: 'Implementation' },
{ id: 'review', label: 'Review', members: ['darang'], description: 'QA & Review' },
{ id: 'deploy', label: 'Deploy', members: ['erang'], description: 'Infra & Deploy' },
];
const FALLBACK_SISTERS: SisterApiItem[] = [
{ name: 'harang', status: 'unknown', role: SISTER_ROLES.harang, currentTask: null },
{ name: 'narang', status: 'unknown', role: SISTER_ROLES.narang, currentTask: null },
{ name: 'darang', status: 'unknown', role: SISTER_ROLES.darang, currentTask: null },
{ name: 'erang', status: 'unknown', role: SISTER_ROLES.erang, currentTask: null },
];
function truthfulStateLabel(state: AgentState, source: 'live' | 'snapshot' | 'fallback'): string {
if (source === 'fallback') return 'fallback';
if (state === 'error') return 'offline';
if (state === 'tool_calling' || state === 'thinking' || state === 'speaking') return 'active working';
if (source === 'snapshot') return 'snapshot only';
return 'connected idle';
}
function statusToAgentState(status: SisterApiItem['status'], hasTask: boolean): AgentState {
if (status === 'offline') return 'error';
if (status === 'working') return hasTask ? 'tool_calling' : 'thinking';
@@ -167,9 +182,23 @@ function mergeSisterData(
return SISTER_ORDER.map((name) => {
const prev = previousMap.get(name);
const status = statusMap.get(name) ?? prev ?? FALLBACK_SISTERS.find((item) => item.name === name)!;
const status = statusMap.get(name) ?? prev;
const live = runtimeMap.get(name);
// If we have no data at all for this sister, build a minimal honest entry
if (!status) {
return {
name,
status: 'unknown' as const,
role: SISTER_ROLES[name],
currentTask: live?.currentTask ?? null,
liveState: live?.mainState,
activeSessionLabel: live?.activeSessionLabel ?? null,
gatewayConnected: live?.gatewayConnected ?? false,
subagents: live?.subagents ?? [],
};
}
return {
...prev,
...status,
@@ -367,13 +396,9 @@ const MobileFlow = styled.div`
`;
const MobileSisterGrid = styled.div`
display: grid;
grid-template-columns: 1fr 1fr;
display: flex;
flex-direction: column;
gap: var(--space-sm);
@media (max-width: 359px) {
grid-template-columns: 1fr;
}
`;
const STATE_CARD_COLORS: Record<AgentState, string> = {
@@ -497,6 +522,72 @@ const MobileActionBtn = styled.button`
}
`;
const MobileGroupBlock = styled.button<{ $hasActive: boolean; $expanded: boolean }>`
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-md);
border: 1px solid ${({ $hasActive }) => ($hasActive ? '#2979FF' : 'var(--border-color)')};
background: ${({ $expanded }) => ($expanded ? 'rgba(255,255,255,0.04)' : 'var(--bg-surface)')};
cursor: pointer;
text-align: left;
font-family: inherit;
color: var(--text-primary);
width: 100%;
`;
const MobileGroupHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-sm);
min-width: 0;
`;
const MobileGroupLabel = styled.div`
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
`;
const MobileGroupMeta = styled.div`
display: flex;
gap: var(--space-xs);
flex-wrap: wrap;
`;
const MobileGroupChip = styled.span<{ $tone: 'active' | 'idle' | 'offline' | 'fallback' }>`
font-family: var(--font-mono);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 6px;
border: 1px solid ${({ $tone }) =>
$tone === 'active' ? '#2979FF' : $tone === 'idle' ? '#00BFA5' : $tone === 'offline' ? '#FF1744' : '#555'};
color: ${({ $tone }) =>
$tone === 'active' ? '#2979FF' : $tone === 'idle' ? '#00BFA5' : $tone === 'offline' ? '#FF1744' : '#777'};
white-space: nowrap;
`;
const MobileGroupDetail = styled.div`
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding-top: var(--space-sm);
border-top: 1px solid var(--border-color);
`;
const MobileOpsSummary = styled.div`
border: 1px solid var(--border-color);
background: var(--bg-surface);
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-sm);
`;
const MobileContextInline = styled.div`
border: 1px solid var(--border-color);
background: var(--bg-surface);
@@ -698,10 +789,10 @@ export default function OfficePage() {
<PageMeta>4 · {SUBAGENT_COUNT} · </PageMeta>
<FreshnessBar>
<FreshnessBadge $type={sisterDataMode}>
: {sisterDataMode === 'live' ? 'live · ws' : sisterDataMode === 'snapshot' ? 'snapshot · poll' : 'fallback · doc'}
source: {sisterDataMode}
</FreshnessBadge>
<FreshnessBadge $type={subagentMode === 'live' ? 'live' : 'fallback'}>
: {subagentMode === 'live' ? 'live · runtime' : 'fallback'}
agents: {subagentMode}
</FreshnessBadge>
<FreshnessBadge $type="snapshot">pipeline: snapshot</FreshnessBadge>
</FreshnessBar>
@@ -736,97 +827,93 @@ export default function OfficePage() {
{/* ─── Mobile layout (<1280px) ─── */}
<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}
aria-pressed={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>
{/* 1. Operational summary — always first */}
<MobileOpsSummary>
<MobileSectionEyebrow> </MobileSectionEyebrow>
<MobileFocusTitle>{pipeline.activeTask}</MobileFocusTitle>
<MobileFocusDetail>{pipeline.focus}</MobileFocusDetail>
<MobileHealthRow>
<MobileHealthChip $ok={sisterDataMode === 'live'}>
ws: {sisterDataMode === 'live' ? 'connected' : 'disconnected'}
{sisterDataMode === 'live' ? 'live' : sisterDataMode === 'snapshot' ? 'snapshot' : 'fallback'}
</MobileHealthChip>
<MobileHealthChip $ok={onlineCount >= 3}>
{onlineCount}/{SISTER_ORDER.length} online
{onlineCount}/{SISTER_ORDER.length} connected
</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 — always show default, expand on selection */}
<MobileContextInline>
<MobileContextHeader>
<MobileContextTitle>
{selected && selectedSisterNode
? `${selected.type === 'sister' ? selectedSisterNode.displayName : selectedSubagent?.label ?? ''} · context`
: '현재 포커스'}
</MobileContextTitle>
{selected && <MobileCloseBtn onClick={() => setSelected(null)}></MobileCloseBtn>}
</MobileContextHeader>
<MobileSelectedSummary>
<SisterAvatar name={(selectedSisterNode ?? sisterNodes[0])?.name ?? 'harang'} size={28} />
<MobileSelectedMeta>
<MobileSelectedName>
{selected && selectedSisterNode
? (selected.type === 'sister' ? selectedSisterNode.displayName : selectedSubagent?.label)
: sisterNodes.find((s) => s.state === 'tool_calling' || s.state === 'thinking')?.displayName ?? sisterNodes[0]?.displayName ?? '—'}
</MobileSelectedName>
<MobileSelectedRole>
{selectedSisterNode?.role ?? sisterNodes[0]?.role ?? ''}
</MobileSelectedRole>
</MobileSelectedMeta>
</MobileSelectedSummary>
{((selectedSisterNode?.currentTask ?? sisterNodes.find((s) => s.state === 'tool_calling' || s.state === 'thinking')?.currentTask)) && (
<MobileTaskBox>
{selectedSisterNode?.currentTask ?? sisterNodes.find((s) => s.state === 'tool_calling' || s.state === 'thinking')?.currentTask ?? ''}
</MobileTaskBox>
)}
<MobileQuickActions>
<MobileActionBtn onClick={() => handleOpenChat((selectedSisterNode ?? sisterNodes[0])?.name ?? 'harang')}></MobileActionBtn>
{SISTER_ORDER.map((name) => (
<MobileActionBtn key={name} onClick={() => handleOpenChat(name)}>
{SISTER_DISPLAY[name]}
</MobileActionBtn>
))}
</MobileQuickActions>
</MobileContextInline>
</MobileOpsSummary>
{/* 5. Chat (if open) */}
{/* 2. Group blocks — group mental model, not 4 individual cards */}
<MobileSisterGrid>
{GROUP_DEFS.map((group) => {
const groupSisters = group.members.map((m) => sisterNodes.find((s) => s.name === m)).filter(Boolean) as typeof sisterNodes;
const hasActive = groupSisters.some((s) => s.state === 'tool_calling' || s.state === 'thinking' || s.state === 'speaking');
const workingCount = groupSisters.filter((s) => s.state === 'tool_calling' || s.state === 'thinking' || s.state === 'speaking').length;
const connectedCount = groupSisters.filter((s) => s.state !== 'error').length;
const isExpanded = selected?.type === 'sister' && group.members.includes(selected.name);
const groupState = truthfulStateLabel(
groupSisters[0]?.state ?? 'idle',
sisterDataMode,
);
return (
<MobileGroupBlock
key={group.id}
$hasActive={hasActive}
$expanded={isExpanded}
onClick={() => handleSelectSister(group.members[0])}
>
<MobileGroupHeader>
<MobileGroupLabel>{group.label}</MobileGroupLabel>
<MobileGroupMeta>
<MobileGroupChip $tone={hasActive ? 'active' : connectedCount > 0 ? 'idle' : sisterDataMode === 'fallback' ? 'fallback' : 'offline'}>
{groupState}
</MobileGroupChip>
{workingCount > 0 && <MobileGroupChip $tone="active">{workingCount} working</MobileGroupChip>}
</MobileGroupMeta>
</MobileGroupHeader>
<MobileFocusDetail>{group.description}</MobileFocusDetail>
{isExpanded && (
<MobileGroupDetail>
{groupSisters.map((sister) => (
<MobileSelectedSummary key={sister.name}>
<SisterAvatar name={sister.name} size={28} />
<MobileSelectedMeta>
<MobileSelectedName>{sister.displayName}</MobileSelectedName>
<MobileSelectedRole>
{truthfulStateLabel(sister.state, sisterDataMode)}
{sister.currentTask ? ` · ${sister.currentTask}` : ''}
</MobileSelectedRole>
</MobileSelectedMeta>
</MobileSelectedSummary>
))}
<MobileQuickActions>
<MobileActionBtn onClick={(e) => { e.stopPropagation(); handleOpenChat(group.members[0]); }}></MobileActionBtn>
</MobileQuickActions>
</MobileGroupDetail>
)}
</MobileGroupBlock>
);
})}
</MobileSisterGrid>
{/* 3. Chat (if open) */}
{chatSister && (
<MobileChatArea>
<ChatWorkspace initialSister={chatSister} onClose={() => setChatSister(null)} />
</MobileChatArea>
)}
{/* 6. Pipeline panel */}
{/* 4. Pipeline panel */}
<PipelinePanel
activeTask={pipeline.activeTask}
focus={pipeline.focus}
@@ -837,7 +924,7 @@ export default function OfficePage() {
freshness={`generated ${formatGeneratedAt(freshness.generatedAt)}`}
/>
{/* 7. Server health */}
{/* 5. Server health */}
<ServerHealthPanel
servers={serverEntries}
dataMode={sisterDataMode}

View File

@@ -3,137 +3,329 @@
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import { API_URL } from '@/lib/config';
import SisterAvatar from '@/components/common/SisterAvatar';
// ─── Styled ───
const MainViewport = styled.div`
// ─── Types ───
interface OrgUnit {
id: string;
name: string;
kind: 'hq' | 'functional_team';
lead: string | null;
memberIds: string[];
}
interface OrgMember {
id: string;
type: 'sister' | 'agent';
role: string;
status: { connection: string; working: string };
source: string;
freshness: { lastSnapshotAt: string | null };
}
interface IndependentAgent {
id: string;
name: string;
ownerSister: string | null;
source: string;
}
interface OrgApiData {
owner?: { name: string; role: string };
sisters?: Array<{
name: string;
role: string;
status?: string;
}>;
orgUnits?: OrgUnit[];
members?: OrgMember[];
reportingLines?: Array<{ from: string; to: string; kind: string }>;
independentAgents?: IndependentAgent[];
}
// ─── Fallback org structure (data-driven, not hardcoded tree) ───
const FALLBACK_ORG_UNITS: OrgUnit[] = [
{ id: 'hq', name: '하나랑 HQ', kind: 'hq', lead: 'harang', memberIds: ['harang'] },
{ id: 'delivery', name: 'Delivery', kind: 'functional_team', lead: 'narang', memberIds: ['narang'] },
{ id: 'review', name: 'QA / Review', kind: 'functional_team', lead: 'darang', memberIds: ['darang'] },
{ id: 'infra', name: 'Infra / Deploy', kind: 'functional_team', lead: 'erang', memberIds: ['erang'] },
];
const FALLBACK_MEMBERS: OrgMember[] = [
{ id: 'harang', type: 'sister', role: 'Orchestrator', status: { connection: 'unknown', working: 'unknown' }, source: 'fallback', freshness: { lastSnapshotAt: null } },
{ id: 'narang', type: 'sister', role: 'Generator', status: { connection: 'unknown', working: 'unknown' }, source: 'fallback', freshness: { lastSnapshotAt: null } },
{ id: 'darang', type: 'sister', role: 'Evaluator', status: { connection: 'unknown', working: 'unknown' }, source: 'fallback', freshness: { lastSnapshotAt: null } },
{ id: 'erang', type: 'sister', role: 'Infra Manager', status: { connection: 'unknown', working: 'unknown' }, source: 'fallback', freshness: { lastSnapshotAt: null } },
];
const FALLBACK_INDEPENDENT_AGENTS: IndependentAgent[] = [
{ id: 'security-auditor', name: 'security-auditor', ownerSister: 'darang', source: 'fallback' },
{ id: 'monitoring', name: 'monitoring', ownerSister: 'erang', source: 'fallback' },
];
const MEMBER_DISPLAY: Record<string, string> = {
harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이',
};
const LXC_ID: Record<string, number> = {
harang: 104, narang: 105, darang: 106, erang: 107,
};
// ─── Helpers ───
function statusLabel(status: OrgMember['status']): string {
if (status.working === 'active') return 'active working';
if (status.connection === 'connected') return 'connected idle';
if (status.connection === 'unknown') return 'unknown';
return 'offline';
}
function statusTone(status: OrgMember['status']): 'active' | 'idle' | 'offline' | 'unknown' {
if (status.working === 'active') return 'active';
if (status.connection === 'connected') return 'idle';
if (status.connection === 'unknown') return 'unknown';
return 'offline';
}
function formatFreshness(ts: string | null): string {
if (!ts) return 'NO DATA';
const diff = Date.now() - new Date(ts).getTime();
if (!Number.isFinite(diff) || diff < 0) return 'UNKNOWN';
const sec = Math.floor(diff / 1000);
if (sec < 60) return `${sec}s AGO`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m AGO`;
return `${Math.floor(min / 60)}h AGO`;
}
function buildOrgFromLegacy(data: OrgApiData): {
orgUnits: OrgUnit[];
members: OrgMember[];
independentAgents: IndependentAgent[];
} {
if (data.orgUnits && data.members) {
return {
orgUnits: data.orgUnits,
members: data.members,
independentAgents: data.independentAgents ?? FALLBACK_INDEPENDENT_AGENTS,
};
}
// Transform legacy /api/org response into org chart model
const sisters = data.sisters ?? [];
if (sisters.length === 0) {
return { orgUnits: FALLBACK_ORG_UNITS, members: FALLBACK_MEMBERS, independentAgents: FALLBACK_INDEPENDENT_AGENTS };
}
const ROLE_TO_TEAM: Record<string, string> = {
Orchestrator: 'hq',
Generator: 'delivery',
Evaluator: 'review',
'Infra Manager': 'infra',
};
const orgUnits: OrgUnit[] = [
{ id: 'hq', name: '하나랑 HQ', kind: 'hq', lead: null, memberIds: [] },
{ id: 'delivery', name: 'Delivery', kind: 'functional_team', lead: null, memberIds: [] },
{ id: 'review', name: 'QA / Review', kind: 'functional_team', lead: null, memberIds: [] },
{ id: 'infra', name: 'Infra / Deploy', kind: 'functional_team', lead: null, memberIds: [] },
];
const members: OrgMember[] = sisters.map((s) => {
const teamId = ROLE_TO_TEAM[s.role] ?? 'delivery';
const unit = orgUnits.find((u) => u.id === teamId);
if (unit) {
unit.memberIds.push(s.name);
if (!unit.lead) unit.lead = s.name;
}
return {
id: s.name,
type: 'sister' as const,
role: s.role,
status: {
connection: s.status === 'online' || s.status === 'working' ? 'connected' : s.status === 'offline' ? 'disconnected' : 'unknown',
working: s.status === 'working' ? 'active' : 'idle',
},
source: 'snapshot',
freshness: { lastSnapshotAt: new Date().toISOString() },
};
});
return { orgUnits, members, independentAgents: FALLBACK_INDEPENDENT_AGENTS };
}
// ─── Styled Components ───
const Shell = styled.div`
display: flex;
flex-direction: column;
gap: var(--space-xl);
min-height: 80vh;
`;
const HeaderInfo = styled.div`
const OrgHeader = styled.div`
display: flex;
gap: var(--space-xl);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
justify-content: space-between;
align-items: baseline;
gap: var(--space-lg);
flex-wrap: wrap;
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--space-md);
span { color: var(--text-primary); }
`;
const TreeContainer = styled.div`
const OrgTitle = styled.h1`
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.01em;
`;
const OrgMeta = styled.div`
display: flex;
gap: var(--space-md);
flex-wrap: wrap;
`;
const OrgBadge = styled.span<{ $tone: 'default' | 'active' | 'fallback' }>`
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 6px;
border: 1px solid ${({ $tone }) =>
$tone === 'active' ? '#00BFA5' : $tone === 'fallback' ? '#777' : 'var(--border-color)'};
color: ${({ $tone }) =>
$tone === 'active' ? '#00BFA5' : $tone === 'fallback' ? '#777' : 'var(--text-secondary)'};
`;
const SectionLabel = styled.h2`
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0;
`;
const HQCard = styled.div`
border: 1px solid var(--border-color);
border-left-width: 3px;
border-left-color: #f6b26b;
padding: var(--space-lg);
background: var(--bg-surface);
display: flex;
flex-direction: column;
align-items: center;
overflow-x: auto;
padding: var(--space-xl) 0;
gap: var(--space-md);
`;
const TeamGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-lg);
@media (max-width: 767px) {
grid-template-columns: 1fr;
}
`;
const TeamCard = styled.div`
border: 1px solid var(--border-color);
padding: var(--space-lg);
background: var(--bg-surface);
display: flex;
flex-direction: column;
gap: var(--space-md);
`;
const TeamTitle = styled.div`
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
`;
const TeamLead = styled.div`
font-size: 12px;
color: var(--text-secondary);
`;
const MemberChipRow = styled.div`
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
`;
const MemberChip = styled.div<{ $tone: 'active' | 'idle' | 'offline' | 'unknown' }>`
display: flex;
align-items: center;
gap: var(--space-xs);
border: 1px solid ${({ $tone }) =>
$tone === 'active' ? '#2979FF' : $tone === 'idle' ? '#00BFA5' : $tone === 'offline' ? '#555' : '#444'};
padding: 4px 8px;
font-size: 12px;
color: var(--text-primary);
`;
const StatusDot = styled.span<{ $tone: 'active' | 'idle' | 'offline' | 'unknown' }>`
width: 6px;
height: 6px;
border-radius: 50%;
background: ${({ $tone }) =>
$tone === 'active' ? '#2979FF' : $tone === 'idle' ? '#00BFA5' : $tone === 'offline' ? '#FF1744' : '#555'};
`;
const MetaRow = styled.div`
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
`;
const AgentSection = styled.div`
display: flex;
flex-direction: column;
gap: var(--space-md);
`;
const AgentCard = styled.div`
border: 1px solid var(--border-color);
padding: var(--space-md) var(--space-lg);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-md);
flex-wrap: wrap;
@media (max-width: 389px) {
flex-direction: column;
align-items: flex-start;
}
`;
const NodeGroup = styled.div`
display: flex;
flex-direction: column;
align-items: center;
position: relative;
`;
const TerminalCard = styled.div<{ $level: 'high' | 'mid' | 'low' | 'none' }>`
border: 1px solid ${({ $level }) => ({
high: '#f43f5e',
mid: '#3b82f6',
low: '#10b981',
none: '#525252',
})[$level]};
border-left-width: 3px;
padding: var(--space-md) var(--space-lg);
background: var(--bg-main);
min-width: 200px;
max-width: 260px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
transition: border-color 0.2s, box-shadow 0.2s;
position: relative;
cursor: default;
&:hover {
box-shadow: 0 12px 36px rgba(0,0,0,0.7);
}
`;
const NodeLabel = styled.div`
font-size: 14px;
const AgentName = styled.div`
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-sm);
span {
color: var(--text-secondary);
margin-right: var(--space-xs);
font-family: var(--font-mono);
font-size: 11px;
}
`;
const BadgeRow = styled.div`
display: flex;
gap: var(--space-xs);
flex-wrap: wrap;
`;
const Badge = styled.div`
background: #1a1a1a;
border: 1px solid var(--border-color);
padding: 2px 6px;
font-size: 9px;
font-family: var(--font-mono);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
const AgentMeta = styled.div`
font-size: 11px;
color: var(--text-secondary);
b { color: var(--text-primary); font-weight: normal; }
`;
const TreeConnector = styled.div`
width: 1px;
height: 24px;
background: var(--border-color);
margin: 0 auto;
`;
const TreeBranch = styled.div`
display: flex;
flex-direction: column;
align-items: center;
`;
const TreeChildren = styled.div`
display: flex;
gap: var(--space-xl);
align-items: flex-start;
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
left: calc(var(--space-xl) / 2 + 100px);
right: calc(var(--space-xl) / 2 + 100px);
height: 1px;
background: var(--border-color);
}
@media (max-width: 767px) {
flex-direction: column;
gap: var(--space-lg);
&::before { display: none; }
}
const EmptyState = styled.div`
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
padding: var(--space-md) 0;
`;
const PageFooter = styled.footer`
@@ -142,139 +334,175 @@ const PageFooter = styled.footer`
padding: var(--space-lg) 0;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-md);
border-top: 1px solid var(--border-color);
font-family: var(--font-mono);
color: var(--text-secondary);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
`;
type Level = 'high' | 'mid' | 'low' | 'none';
interface OrgData {
owner: { name: string; role: string };
sisters: Array<{
name: string;
role: string;
description?: string;
status?: string;
lxcId?: number;
}>;
pipeline: Array<{ from: string; to: string; label: string }>;
}
const LEVEL_MAP: Record<string, Level> = {
Orchestrator: 'high',
Generator: 'mid',
Evaluator: 'mid',
'Infra Manager': 'low',
};
const ROLE_TAG: Record<string, string> = {
Orchestrator: 'ORCH',
Generator: 'GEN',
Evaluator: 'EVAL',
'Infra Manager': 'INFRA',
};
const LXC_ID: Record<string, number> = {
harang: 104,
narang: 105,
darang: 106,
erang: 107,
};
// ─── Component ───
export default function OrgPage() {
const [orgData, setOrgData] = useState<OrgData | null>(null);
const ts = new Date().toLocaleDateString('ko-KR').replace(/\. /g, '.').replace('.', '') + '_' +
new Date().toLocaleTimeString('ko-KR', { hour12: false, hour: '2-digit', minute: '2-digit' });
const [orgData, setOrgData] = useState<OrgApiData | null>(null);
const [dataSource, setDataSource] = useState<'live' | 'snapshot' | 'fallback'>('fallback');
useEffect(() => {
fetch(`${API_URL}/api/org`)
.then((r) => r.json())
.then(setOrgData)
.catch(() => {});
.then((data) => {
setOrgData(data);
setDataSource(data.orgUnits ? 'snapshot' : data.sisters?.length > 0 ? 'snapshot' : 'fallback');
})
.catch(() => {
setDataSource('fallback');
});
}, []);
const sisters = orgData?.sisters ?? [];
const { orgUnits, members, independentAgents } = orgData
? buildOrgFromLegacy(orgData)
: { orgUnits: FALLBACK_ORG_UNITS, members: FALLBACK_MEMBERS, independentAgents: FALLBACK_INDEPENDENT_AGENTS };
const memberMap = new Map(members.map((m) => [m.id, m]));
const hqUnit = orgUnits.find((u) => u.kind === 'hq');
const functionalTeams = orgUnits.filter((u) => u.kind === 'functional_team');
const totalMembers = members.length;
const totalUnits = orgUnits.length;
return (
<MainViewport>
<HeaderInfo>
<div>SYS_TYPE: <span>CORE_ROOT_01</span></div>
<div>TIMESTAMP: <span style={{ color: 'var(--text-secondary)' }}>{ts}</span></div>
</HeaderInfo>
<Shell>
<OrgHeader>
<OrgTitle></OrgTitle>
<OrgMeta>
<OrgBadge $tone={dataSource === 'fallback' ? 'fallback' : 'default'}>
source: {dataSource}
</OrgBadge>
<OrgBadge $tone="default">{totalUnits} units</OrgBadge>
<OrgBadge $tone="default">{totalMembers} members</OrgBadge>
<OrgBadge $tone="default">{independentAgents.length} agents</OrgBadge>
</OrgMeta>
</OrgHeader>
<TreeContainer>
<NodeGroup>
{/* HQ */}
<TerminalCard $level="high">
<NodeLabel><span>[HQ]</span> </NodeLabel>
<BadgeRow>
<Badge>MEM: <b>{sisters.length + 1}</b></Badge>
<Badge>S_ADMIN</Badge>
<Badge>LV_09</Badge>
</BadgeRow>
</TerminalCard>
<TreeConnector />
{/* HQ Section */}
{hqUnit && (
<>
<SectionLabel>HQ</SectionLabel>
<HQCard>
<TeamTitle>{hqUnit.name}</TeamTitle>
<TeamLead>
{hqUnit.lead
? `LEAD: ${MEMBER_DISPLAY[hqUnit.lead] ?? hqUnit.lead}`
: 'LEAD 미정'}
</TeamLead>
<MemberChipRow>
{hqUnit.memberIds.map((id) => {
const member = memberMap.get(id);
const tone = member ? statusTone(member.status) : 'unknown';
return (
<MemberChip key={id} $tone={tone}>
<SisterAvatar name={id} size={20} />
<StatusDot $tone={tone} />
{MEMBER_DISPLAY[id] ?? id}
</MemberChip>
);
})}
</MemberChipRow>
<MetaRow>
{hqUnit.memberIds.map((id) => {
const member = memberMap.get(id);
return (
<span key={id}>
{MEMBER_DISPLAY[id] ?? id}: {member ? statusLabel(member.status) : 'unknown'}
{LXC_ID[id] ? ` · LXC ${LXC_ID[id]}` : ''}
</span>
);
})}
</MetaRow>
</HQCard>
</>
)}
{/* 자매 레벨 */}
<TreeBranch>
<TreeChildren>
{/* 하랑이 (Orchestrator) */}
{sisters.filter((s) => s.role === 'Orchestrator').map((s) => (
<NodeGroup key={s.name}>
<TerminalCard $level={LEVEL_MAP[s.role] ?? 'none'}>
<NodeLabel>
<span>[{ROLE_TAG[s.role] ?? s.role}]</span>
{s.name === 'harang' ? '하랑이' : s.name}
</NodeLabel>
<BadgeRow>
<Badge>LXC: <b>{LXC_ID[s.name] ?? '---'}</b></Badge>
<Badge>{ROLE_TAG[s.role] ?? s.role}</Badge>
<Badge>{s.status === 'online' ? 'ACTIVE' : s.status === 'offline' ? 'STBY' : 'RUN'}</Badge>
</BadgeRow>
</TerminalCard>
<TreeConnector />
<TreeBranch>
<TreeChildren>
{/* 나랑/다랑/이랑 */}
{sisters.filter((s2) => s2.role !== 'Orchestrator').map((s2) => (
<NodeGroup key={s2.name}>
<TerminalCard $level={LEVEL_MAP[s2.role] ?? 'none'}>
<NodeLabel>
{s2.name === 'narang' ? '나랑이' : s2.name === 'darang' ? '다랑이' : '이랑이'}
</NodeLabel>
<BadgeRow>
<Badge>LXC: <b>{LXC_ID[s2.name] ?? '---'}</b></Badge>
<Badge>{ROLE_TAG[s2.role] ?? s2.role}</Badge>
<Badge>{s2.status === 'online' ? 'ON' : s2.status === 'offline' ? '--' : 'RUN'}</Badge>
</BadgeRow>
</TerminalCard>
</NodeGroup>
))}
</TreeChildren>
</TreeBranch>
</NodeGroup>
))}
{/* Functional Teams */}
<SectionLabel>FUNCTIONAL TEAMS</SectionLabel>
<TeamGrid>
{functionalTeams.map((team) => (
<TeamCard key={team.id}>
<TeamTitle>{team.name}</TeamTitle>
<TeamLead>
{team.lead
? `LEAD: ${MEMBER_DISPLAY[team.lead] ?? team.lead}`
: 'LEAD 미정'}
</TeamLead>
<MemberChipRow>
{team.memberIds.map((id) => {
const member = memberMap.get(id);
const tone = member ? statusTone(member.status) : 'unknown';
return (
<MemberChip key={id} $tone={tone}>
<SisterAvatar name={id} size={20} />
<StatusDot $tone={tone} />
{MEMBER_DISPLAY[id] ?? id}
</MemberChip>
);
})}
</MemberChipRow>
<MetaRow>
{team.memberIds.map((id) => {
const member = memberMap.get(id);
return (
<span key={id}>
{member?.role ?? 'unknown'}
{member ? ` · ${statusLabel(member.status)}` : ''}
{member ? ` · ${member.source}` : ''}
</span>
);
})}
<span>freshness: {formatFreshness(
team.memberIds
.map((id) => memberMap.get(id)?.freshness.lastSnapshotAt)
.filter(Boolean)
.sort()
.at(-1) ?? null
)}</span>
</MetaRow>
</TeamCard>
))}
</TeamGrid>
{/* Orchestrator 없을 때 fallback */}
{sisters.filter((s) => s.role === 'Orchestrator').length === 0 && (
<NodeGroup>
<TerminalCard $level="none">
<NodeLabel> </NodeLabel>
<BadgeRow><Badge>N/A</Badge></BadgeRow>
</TerminalCard>
</NodeGroup>
)}
</TreeChildren>
</TreeBranch>
</NodeGroup>
</TreeContainer>
{/* Independent Agents */}
<SectionLabel>INDEPENDENT AGENTS</SectionLabel>
<AgentSection>
{independentAgents.length === 0 ? (
<EmptyState>INDEPENDENT AGENTS </EmptyState>
) : (
independentAgents.map((agent) => (
<AgentCard key={agent.id}>
<div>
<AgentName>{agent.name}</AgentName>
<AgentMeta>
{agent.ownerSister
? `owner: ${MEMBER_DISPLAY[agent.ownerSister] ?? agent.ownerSister}`
: 'independent'}
</AgentMeta>
</div>
<MetaRow>
<span>source: {agent.source}</span>
</MetaRow>
</AgentCard>
))
)}
</AgentSection>
<PageFooter>
<div>TOTAL_NODES: {String(sisters.length + 1).padStart(2, '0')}</div>
<div>STATUS: SYNC_COMPLETE</div>
<div>UNITS: {String(totalUnits).padStart(2, '0')}</div>
<div>MEMBERS: {String(totalMembers).padStart(2, '0')}</div>
<div>AGENTS: {String(independentAgents.length).padStart(2, '0')}</div>
<div>SOURCE: {dataSource.toUpperCase()}</div>
</PageFooter>
</MainViewport>
</Shell>
);
}

View File

@@ -268,7 +268,7 @@ const ConnectionDot = styled.span<{ $connected: boolean }>`
width: 8px;
height: 8px;
border-radius: 50%;
background: ${({ $connected }) => ($connected ? '#8dffb2' : '#ff8d7a')};
background: ${({ $connected }) => ($connected ? '#8dffb2' : '#888')};
box-shadow: ${({ $connected }) => ($connected ? '0 0 8px rgba(141,255,178,0.45)' : 'none')};
flex-shrink: 0;
`;
@@ -958,8 +958,8 @@ export default function DashboardPage() {
</div>
<Summary>RUNTIME / EVENTS / DOCS</Summary>
<InlineStats>
<Chip $tone={connected ? 'active' : 'muted'}>{connected ? 'socket on' : 'socket off'}</Chip>
<Chip $tone="default">snapshot {formatSyncLabel(derived.freshness.generatedAt)}</Chip>
<Chip $tone={connected ? 'active' : 'default'}>{connected ? 'EVENT STREAM ON' : 'EVENT STREAM OFF'}</Chip>
<Chip $tone="default">{formatSyncLabel(derived.freshness.generatedAt)}</Chip>
<Chip $tone={derived.reviewProjects > 0 ? 'warning' : 'default'}>qa {String(derived.reviewProjects).padStart(2, '0')}</Chip>
<Chip $tone={derived.readyDeploy > 0 ? 'active' : 'muted'}>deploy {String(derived.readyDeploy).padStart(2, '0')}</Chip>
</InlineStats>
@@ -999,21 +999,33 @@ export default function DashboardPage() {
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<ConnectionDot $connected={connected} />
<div>
<Eyebrow>Event stream</Eyebrow>
<div style={{ fontSize: 14, color: 'var(--text-primary)', marginTop: 4 }}>{connected ? 'SOCKET ON / EVENT MIRRORED' : 'SOCKET OFF / POLLING'}</div>
<Eyebrow>Connection</Eyebrow>
<div style={{ fontSize: 14, color: 'var(--text-primary)', marginTop: 4 }}>{connected ? 'EVENT STREAM ON' : 'EVENT STREAM OFF'}</div>
</div>
</div>
<LabelMeta><span>SYNC:</span>{formatSyncLabel(derived.freshness.activityLatestAt)}</LabelMeta>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}>
<LabelMeta><span>FRESHNESS:</span>{formatSyncLabel(derived.freshness.activityLatestAt)}</LabelMeta>
<LabelMeta><span>SOURCE:</span>{connected ? 'MIRRORED EVENT' : 'SNAPSHOT'}</LabelMeta>
</div>
</ConnectionCard>
<OperatorRow>
<SisterAvatar name={derived.activeProject?.ownerSister ?? 'narang'} size={44} />
<OperatorText>
<OperatorName>{SISTER_META[derived.activeProject?.ownerSister ?? 'narang']?.label ?? '나랑이'}</OperatorName>
<OperatorMeta>
{SISTER_META[derived.activeProject?.ownerSister ?? 'narang']?.role ?? 'Implementation'} / SNAPSHOT OWNER
</OperatorMeta>
</OperatorText>
{derived.activeProject?.ownerSister ? (
<>
<SisterAvatar name={derived.activeProject.ownerSister} size={44} />
<OperatorText>
<OperatorName>{SISTER_META[derived.activeProject.ownerSister]?.label ?? derived.activeProject.ownerSister}</OperatorName>
<OperatorMeta>
{SISTER_META[derived.activeProject.ownerSister]?.role ?? 'unknown'} / SNAPSHOT OWNER
</OperatorMeta>
</OperatorText>
</>
) : (
<OperatorText>
<OperatorName>NO ACTIVE OWNER</OperatorName>
<OperatorMeta>UNASSIGNED</OperatorMeta>
</OperatorText>
)}
</OperatorRow>
<QuickLinks>
@@ -1104,7 +1116,7 @@ export default function DashboardPage() {
<PanelTitleBlock>
<Eyebrow>Activity feed</Eyebrow>
<PanelTitle>EVENT MIRROR</PanelTitle>
<PanelDesc>WEBSOCKET + POLLING SNAPSHOT</PanelDesc>
<PanelDesc>{connected ? 'SOURCE: MIRRORED EVENT' : 'SOURCE: SNAPSHOT'}</PanelDesc>
</PanelTitleBlock>
<LabelMeta><span>EVENT:</span>{String(activityItems.length).padStart(2, '0')}</LabelMeta>
</PanelHeader>
@@ -1141,7 +1153,7 @@ export default function DashboardPage() {
<PanelTitleBlock>
<Eyebrow>Sprint metrics</Eyebrow>
<PanelTitle>SPRINT METRICS</PanelTitle>
<PanelDesc>POLLING SNAPSHOT</PanelDesc>
<PanelDesc>SOURCE: SNAPSHOT</PanelDesc>
</PanelTitleBlock>
<LabelMeta><span>SPRINT:</span>{String(derived.totalSprints).padStart(2, '0')}</LabelMeta>
</PanelHeader>
@@ -1187,7 +1199,7 @@ export default function DashboardPage() {
<PanelTitleBlock>
<Eyebrow>Infrastructure overview</Eyebrow>
<PanelTitle>RUNTIME SURFACE</PanelTitle>
<PanelDesc>LIVE / SNAPSHOT / DOC-DERIVED</PanelDesc>
<PanelDesc>SOURCE: {connected ? 'LIVE' : 'SNAPSHOT'} / DOC-DERIVED</PanelDesc>
</PanelTitleBlock>
<LabelMeta><span>NODE:</span>{String(sisters.length).padStart(2, '0')}</LabelMeta>
</PanelHeader>
@@ -1208,7 +1220,7 @@ export default function DashboardPage() {
<PanelTitleBlock>
<Eyebrow>Mistake log & harness</Eyebrow>
<PanelTitle>RULE / LOG BOARD</PanelTitle>
<PanelDesc>DOC-DERIVED / ACTIVITY</PanelDesc>
<PanelDesc>SOURCE: DOC-DERIVED / ACTIVITY</PanelDesc>
</PanelTitleBlock>
<LabelMeta><span>RULE:</span>{String(derived.board.length).padStart(2, '0')}</LabelMeta>
</PanelHeader>

View File

@@ -46,6 +46,13 @@ const GitItem = styled.a`border:1px solid var(--border-color);padding:12px var(-
const GitPrimary = styled.div`display:flex;flex-direction:column;gap:4px;min-width:0;`;
const GitTitle = styled.div`font-size:13px;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
const ParticipantSection = styled.div`display:flex;flex-direction:column;gap:var(--space-md);`;
const ParticipantCard = styled.div`border:1px solid var(--border-color);padding:var(--space-sm) var(--space-md);display:flex;flex-direction:column;gap:6px;`;
const ParticipantBadgeRow = styled.div`display:flex;gap:var(--space-xs);flex-wrap:wrap;`;
const WhyBadge = styled.span`font-family:var(--font-mono);font-size:9px;text-transform:uppercase;letter-spacing:0.06em;padding:2px 6px;border:1px solid #2979FF;color:#2979FF;white-space:nowrap;`;
const SourceBadge = styled.span`font-family:var(--font-mono);font-size:9px;text-transform:uppercase;letter-spacing:0.06em;padding:2px 6px;border:1px solid #777;color:#777;white-space:nowrap;`;
const EmptyParticipant = styled.div`font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.06em;padding:var(--space-md) 0;`;
const ROLE_LABEL: Record<string, string> = { harang: 'ORCHESTRATOR', narang: 'GENERATOR', darang: 'EVALUATOR', erang: 'INFRA' };
const ROLE_NAME: Record<string, string> = { harang: '하랑', narang: '나랑', darang: '다랑', erang: '이랑' };
@@ -87,12 +94,37 @@ export default function ProjectDetailPage() {
}, [tab, id, commits.length, branches.length, pulls.length]);
const allTasks = useMemo(() => tasks.flatMap((sprint) => sprint.tasks ?? []), [tasks]);
const assignedNodes = useMemo(() => {
const names = Array.from(new Set(allTasks.map((task) => task.assignee).filter(Boolean)));
return (names.length > 0 ? names : ['harang', 'narang', 'darang', 'erang']).map((name) => ({
name,
status: sisters.find((sister) => sister.name === name)?.status ?? 'offline',
}));
const participantModel = useMemo(() => {
const taskAssigneeNames = Array.from(new Set(allTasks.map((task) => task.assignee).filter(Boolean)));
const taskCountByAssignee: Record<string, number> = {};
for (const task of allTasks) {
if (task.assignee) {
taskCountByAssignee[task.assignee] = (taskCountByAssignee[task.assignee] ?? 0) + 1;
}
}
const participants: { name: string; whyVisible: string[]; source: string; status: string }[] = [];
for (const name of taskAssigneeNames) {
const reasons: string[] = [];
if (taskCountByAssignee[name]) reasons.push('task_assignee');
const sisterStatus = sisters.find((s) => s.name === name)?.status ?? 'offline';
if (sisterStatus === 'working' || sisterStatus === 'online') reasons.push('current_implementer');
participants.push({
name,
whyVisible: reasons.length > 0 ? reasons : ['task_assignee'],
source: 'snapshot',
status: sisterStatus,
});
}
return {
currentOwners: [] as { name: string; source: string }[],
participants,
taskAssignees: taskAssigneeNames.map((name) => ({
name,
taskCount: taskCountByAssignee[name] ?? 0,
})),
};
}, [allTasks, sisters]);
const hotfixes = history.filter((entry) => entry.kind === 'hotfix');
const currentSprintMeta = useMemo(() => {
@@ -198,21 +230,72 @@ export default function ProjectDetailPage() {
</Panel>
<Panel $column="side" $mobileOrder={6}>
<SectionTitle><span>ASSIGNED NODES</span><LabelMeta>LIVE STATUS</LabelMeta></SectionTitle>
<NodeStack>
{assignedNodes.map((node) => (
<NodeMiniCard key={node.name}>
<NodeInfo>
<SisterAvatar name={node.name} size={28} />
<div>
<div style={{ fontSize: '13px', color: 'var(--text-primary)', fontWeight: 600 }}>{ROLE_NAME[node.name] ?? node.name}</div>
<Meta>{ROLE_LABEL[node.name] ?? 'NODE'}</Meta>
</div>
</NodeInfo>
<NodeDot $status={node.status} />
</NodeMiniCard>
))}
</NodeStack>
<SectionTitle><span>CURRENT OWNERS</span><LabelMeta>SNAPSHOT</LabelMeta></SectionTitle>
<ParticipantSection>
{participantModel.currentOwners.length === 0 ? (
<EmptyParticipant>NO ACTIVE OWNER</EmptyParticipant>
) : (
participantModel.currentOwners.map((owner) => (
<NodeMiniCard key={owner.name}>
<NodeInfo>
<SisterAvatar name={owner.name} size={28} />
<div>
<div style={{ fontSize: '13px', color: 'var(--text-primary)', fontWeight: 600 }}>{ROLE_NAME[owner.name] ?? owner.name}</div>
<Meta>{ROLE_LABEL[owner.name] ?? 'OWNER'}</Meta>
</div>
</NodeInfo>
<SourceBadge>{owner.source}</SourceBadge>
</NodeMiniCard>
))
)}
</ParticipantSection>
<SectionTitle style={{ marginTop: 'var(--space-lg)' }}><span>PARTICIPANTS</span><LabelMeta>SNAPSHOT</LabelMeta></SectionTitle>
<ParticipantSection>
{participantModel.participants.length === 0 ? (
<EmptyParticipant>NO CONFIRMED ACTIVE PARTICIPANT</EmptyParticipant>
) : (
participantModel.participants.map((p) => (
<ParticipantCard key={p.name}>
<NodeInfo>
<SisterAvatar name={p.name} size={28} />
<div>
<div style={{ fontSize: '13px', color: 'var(--text-primary)', fontWeight: 600 }}>{ROLE_NAME[p.name] ?? p.name}</div>
<Meta>{ROLE_LABEL[p.name] ?? 'PARTICIPANT'}</Meta>
</div>
</NodeInfo>
<ParticipantBadgeRow>
{p.whyVisible.map((reason) => (
<WhyBadge key={reason}>{reason}</WhyBadge>
))}
<SourceBadge>{p.source}</SourceBadge>
</ParticipantBadgeRow>
</ParticipantCard>
))
)}
</ParticipantSection>
<SectionTitle style={{ marginTop: 'var(--space-lg)' }}><span>TASK ASSIGNEES</span><LabelMeta>{participantModel.taskAssignees.length} ASSIGNEES</LabelMeta></SectionTitle>
<ParticipantSection>
{participantModel.taskAssignees.length === 0 ? (
<EmptyParticipant>NO TASK ASSIGNEE DATA</EmptyParticipant>
) : (
<NodeStack>
{participantModel.taskAssignees.map((a) => (
<NodeMiniCard key={a.name}>
<NodeInfo>
<SisterAvatar name={a.name} size={28} />
<div>
<div style={{ fontSize: '13px', color: 'var(--text-primary)', fontWeight: 600 }}>{ROLE_NAME[a.name] ?? a.name}</div>
<Meta>{a.taskCount} TASKS</Meta>
</div>
</NodeInfo>
<SourceBadge>snapshot</SourceBadge>
</NodeMiniCard>
))}
</NodeStack>
)}
</ParticipantSection>
</Panel>
</ProjectGrid>
)}