feat: SPRINT-017 mobile-first office dashboard overhaul

- Mobile summary view with sister cards, focus, health, quick actions
- Compact card flow replaces OfficeScene on mobile (no shrink)
- ContextPanel absorbed inline on mobile
- PipelinePanel vertical stack on mobile
- ServerHealthPanel 2-col mobile, 1-col below 360px
- ChatWorkspace tab padding optimized for narrow screens
- Desktop 1280+ layout preserved as-is
- Breakpoints: 360 / 390 / 768 / 1280+
This commit is contained in:
2026-04-09 09:40:14 +09:00
parent ba800ea8e3
commit 4434e75a12
4 changed files with 390 additions and 5 deletions

View File

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

View File

@@ -124,6 +124,8 @@ const SisterTab = styled.button<{ $active: boolean }>`
border-bottom: 2px solid ${({ $active }) => ($active ? 'var(--text-primary)' : 'transparent')};
white-space: nowrap;
flex-shrink: 0;
padding: var(--space-xs) var(--space-sm);
gap: 4px;
}
`;

View File

@@ -74,6 +74,11 @@ const Focus = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@media (max-width: 767px) {
max-width: 100%;
white-space: normal;
}
`;
const Stats = styled.div`
@@ -110,6 +115,11 @@ const NodeRow = styled.div`
overflow-x: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
@media (max-width: 767px) {
flex-direction: column;
overflow-x: visible;
}
`;
const stateColors: Record<PipelineNode['state'], string> = {
@@ -128,6 +138,10 @@ const NodeCard = styled.div<{ $state: PipelineNode['state'] }>`
display: flex;
flex-direction: column;
gap: 4px;
@media (max-width: 767px) {
min-width: 0;
}
`;
const NodeName = styled.div`
@@ -179,6 +193,13 @@ const Connector = styled.div<{ $active: boolean }>`
background: ${({ $active }) => $active ? 'rgba(245,245,245,0.8)' : 'transparent'};
animation: ${({ $active }) => $active ? css`${flowAnim} 1.6s linear infinite` : 'none'};
}
@media (max-width: 767px) {
width: 1px;
height: 16px;
align-self: flex-start;
margin-left: var(--space-lg);
}
`;
const FreshnessNote = styled.div`

View File

@@ -73,6 +73,10 @@ const Grid = styled.div`
@media (max-width: 767px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 359px) {
grid-template-columns: 1fr;
}
`;
const statusColors: Record<ServerEntry['status'], string> = {