fix: SPRINT-017 review fixes

1. Desktop breakpoint 768px → 1280px+ (tablet keeps mobile flow)
2. Default context always rendered (shows working sister when none selected)
3. MobileSisterCard aria-pressed for accessibility
4. ChatWorkspace localStorage → cookie-based withSessionRequest
This commit is contained in:
2026-04-09 09:51:12 +09:00
parent 4434e75a12
commit 622eac3a10
2 changed files with 52 additions and 60 deletions

View File

@@ -276,7 +276,7 @@ const PageMeta = styled.div`
text-transform: uppercase;
letter-spacing: 0.06em;
@media (max-width: 767px) {
@media (max-width: 1279px) {
display: none;
}
`;
@@ -287,7 +287,7 @@ const FreshnessBar = styled.div`
flex-wrap: wrap;
margin-left: auto;
@media (max-width: 767px) {
@media (max-width: 1279px) {
margin-left: 0;
width: 100%;
}
@@ -304,14 +304,14 @@ const FreshnessBadge = styled.span<{ $type: 'live' | 'snapshot' | 'fallback' }>`
opacity: 0.8;
`;
/* ─── Desktop layout (≥768px): scene + context side-by-side ─── */
/* ─── Desktop layout (≥1280px): scene + context side-by-side ─── */
const DesktopMain = styled.div`
display: flex;
gap: 0;
min-height: 0;
flex: 1;
@media (max-width: 767px) {
@media (max-width: 1279px) {
display: none;
}
`;
@@ -328,7 +328,7 @@ const ChatArea = styled.div`
height: 520px;
flex-shrink: 0;
@media (max-width: 767px) {
@media (max-width: 1279px) {
height: 400px;
}
`;
@@ -338,7 +338,7 @@ const BottomPanels = styled.div`
flex-direction: column;
gap: var(--space-lg);
@media (max-width: 767px) {
@media (max-width: 1279px) {
display: none;
}
`;
@@ -355,13 +355,13 @@ const LoadingOverlay = styled.div`
letter-spacing: 0.08em;
`;
/* ─── Mobile layout (<768px) ─── */
/* ─── Mobile layout (<1280px) ─── */
const MobileFlow = styled.div`
display: none;
flex-direction: column;
gap: var(--space-lg);
@media (max-width: 767px) {
@media (max-width: 1279px) {
display: flex;
}
`;
@@ -707,7 +707,7 @@ export default function OfficePage() {
</FreshnessBar>
</PageHeader>
{/* ─── Desktop layout (≥768px) ─── */}
{/* ─── Desktop layout (≥1280px) ─── */}
<DesktopMain>
<SceneColumn>
<OfficeScene
@@ -734,7 +734,7 @@ export default function OfficePage() {
/>
</DesktopMain>
{/* ─── Mobile layout (<768px) ─── */}
{/* ─── Mobile layout (<1280px) ─── */}
<MobileFlow>
{/* 1. Sister status cards */}
<MobileSisterGrid>
@@ -745,6 +745,7 @@ export default function OfficePage() {
key={sister.name}
$state={sister.state}
$selected={isSelected}
aria-pressed={isSelected}
onClick={() => handleSelectSister(sister.name)}
>
<SisterAvatar name={sister.name} size={32} />
@@ -785,34 +786,38 @@ export default function OfficePage() {
))}
</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>
)}
{/* 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>
</MobileQuickActions>
</MobileContextInline>
{/* 5. Chat (if open) */}
{chatSister && (

View File

@@ -4,6 +4,7 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import styled, { keyframes } from 'styled-components';
import SisterAvatar from '@/components/common/SisterAvatar';
import { API_URL } from '@/lib/config';
import { withSessionRequest } from '@/lib/csrf';
import type { SisterName, AgentState } from './OfficeScene';
interface RuntimeMessage {
@@ -409,10 +410,8 @@ function runtimeStateLabel(state: AgentState): string {
return 'idle';
}
function getToken(): string {
if (typeof window === 'undefined') return '';
return localStorage.getItem('hanarang_access_token') ?? '';
}
// Fix #4: Remove localStorage token — use cookie-based auth via withSessionRequest
// Token is handled by HttpOnly cookies + CSRF, no client-side access needed
function mergeMessages(prev: ChatMessage[], incoming: ChatMessage[]) {
const map = new Map<string, ChatMessage>();
@@ -443,7 +442,7 @@ export default function ChatWorkspace({ initialSister, onClose }: ChatWorkspaceP
const messages = useMemo(() => allMessages[activeSister] ?? [], [allMessages, activeSister]);
const runtime = runtimeBySister[activeSister] ?? null;
const hasToken = Boolean(getToken());
// Cookie-based auth always available (no token check needed)
useEffect(() => {
setActiveSister(initialSister);
@@ -489,16 +488,7 @@ export default function ChatWorkspace({ initialSister, onClose }: ChatWorkspaceP
const text = input.trim();
if (!text || sending) return;
const token = getToken();
if (!token) {
setMessages(activeSister, (prev) => mergeMessages(prev, [{
id: `sys-${crypto.randomUUID()}`,
role: 'assistant',
content: '로그인이 풀린 것 같아. 다시 로그인한 뒤 시도해줘.',
ts: new Date().toISOString(),
}]));
return;
}
// Cookie-based auth — no token check needed
const userMessage: ChatMessage = {
id: `user-${crypto.randomUUID()}`,
@@ -512,14 +502,11 @@ export default function ChatWorkspace({ initialSister, onClose }: ChatWorkspaceP
setSending(true);
try {
const res = await fetch(`${API_URL}/api/sisters/${activeSister}/chat`, {
const res = await fetch(`${API_URL}/api/sisters/${activeSister}/chat`, withSessionRequest({
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text }),
});
}, { csrf: true }));
const data = await res.json();
if (!res.ok) {
@@ -622,7 +609,7 @@ export default function ChatWorkspace({ initialSister, onClose }: ChatWorkspaceP
onKeyDown={handleKeyDown}
rows={1}
/>
<SendBtn onClick={() => void handleSend()} disabled={!input.trim() || sending || !hasToken}>
<SendBtn onClick={() => void handleSend()} disabled={!input.trim() || sending}>
{sending ? '전송 중' : '전송'}
</SendBtn>
</InputArea>