對話可切全螢幕,亦可縮回側邊面板回到辦公室

摘要:
與 claude-office 同步:LIVE 對話新增全螢幕切換,展開為置中聊天卡片,
可用「回到辦公室」鈕或 Esc 縮回。

根本原因:
對話擠在側欄不利長對話閱讀。

影響:
純 UI 覆蓋層;側欄與全螢幕共用 ChatConversation,狀態同步。

修法:
抽出 ChatConversation(variant panel|full)、ChatFullscreen 覆蓋層、
store chatFullscreen 狀態、全螢幕毛玻璃版面。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 15:19:35 +08:00
co-authored by Claude Fable 5
parent 89a8b75b61
commit f62c40041b
6 changed files with 289 additions and 116 deletions
+33 -26
View File
@@ -1,26 +1,33 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { FloorPlan } from "./components/FloorPlan"; import { ChatFullscreen } from "./components/ChatFullscreen";
import { HeaderBar } from "./components/HeaderBar"; import { FloorPlan } from "./components/FloorPlan";
import { SidePanel } from "./components/SidePanel"; import { HeaderBar } from "./components/HeaderBar";
import { setMode, startRuntime } from "./sim/runtime"; import { SidePanel } from "./components/SidePanel";
import { loadPreferredMode, useOfficeStore } from "./store/office-store"; import { setMode, startRuntime } from "./sim/runtime";
import { loadPreferredMode, useOfficeStore } from "./store/office-store";
export default function App() {
const theme = useOfficeStore((s) => s.theme); export default function App() {
const panelOpen = useOfficeStore((s) => s.panelOpen); const theme = useOfficeStore((s) => s.theme);
const panelOpen = useOfficeStore((s) => s.panelOpen);
useEffect(() => { const chatFullscreen = useOfficeStore((s) => s.chatFullscreen);
startRuntime(); const selectedAgentId = useOfficeStore((s) => s.selectedAgentId);
if (loadPreferredMode() === "live") setMode("live"); const mode = useOfficeStore((s) => s.mode);
}, []);
useEffect(() => {
return ( startRuntime();
<div className={`app ${theme}`}> if (loadPreferredMode() === "live") setMode("live");
<HeaderBar /> }, []);
<div className={`main-row ${panelOpen ? "" : "panel-closed"}`}>
<FloorPlan /> const showFullscreenChat = chatFullscreen && mode === "live" && selectedAgentId !== null;
{panelOpen && <SidePanel />}
</div> return (
</div> <div className={`app ${theme}`}>
); <HeaderBar />
} <div className={`main-row ${panelOpen ? "" : "panel-closed"}`}>
<FloorPlan />
{panelOpen && <SidePanel />}
</div>
{showFullscreenChat && <ChatFullscreen />}
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useRef, useState } from "react";
import { useOfficeStore } from "@/store/office-store";
import { resetChat, sendChat } from "@/gateway/live";
/**
* Two-way chat with one project — each message runs headless and is stitched
* to the previous with --resume. Shared by the side panel and the full-screen
* overlay via the `variant` prop.
*/
export function ChatConversation({
agentId,
variant,
}: {
agentId: string;
variant: "panel" | "full";
}) {
const thread = useOfficeStore((s) => s.chats.get(agentId));
const setChatFullscreen = useOfficeStore((s) => s.setChatFullscreen);
const isFull = useOfficeStore((s) => s.chatFullscreen);
const [input, setInput] = useState("");
const [fromProject, setFromProject] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const busy = thread?.busy ?? false;
const messages = thread?.messages ?? [];
const started = messages.length > 0;
useEffect(() => {
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
}, [messages.length, busy]);
const send = () => {
const text = input.trim();
if (!text || busy) return;
sendChat(agentId, text, fromProject);
setInput("");
};
return (
<div className={`chat-panel ${variant === "full" ? "chat-panel-full" : ""}`}>
<div className="chat-head">
<h3></h3>
<div className="chat-head-actions">
{started && (
<button className="link-btn" onClick={() => resetChat(agentId)} disabled={busy}>
🔄
</button>
)}
<button
className="link-btn"
onClick={() => setChatFullscreen(!isFull)}
title={isFull ? "縮回側邊面板" : "全螢幕對話"}
>
{isFull ? "⊟ 縮小" : "⛶ 全螢幕"}
</button>
</div>
</div>
{started && (
<div className="chat-list" ref={listRef}>
{messages.map((m, i) => (
<div key={i} className={`chat-msg ${m.role}`}>
{m.text}
</div>
))}
{busy && <div className="chat-msg pending"> ,</div>}
</div>
)}
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={started ? "繼續對話…(Enter 送出)" : "跟這個專案的 Codex 對話…(Enter 送出)"}
rows={variant === "full" ? 3 : 2}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
}}
/>
<div className="task-composer-row">
{!started ? (
<label
className="tip"
data-tip="第一句話從該專案最近一段「已結束」的 session 接續(codex exec resume)。注意:無法插入正在終端機進行中的互動對話。"
>
<input
type="checkbox"
checked={fromProject}
onChange={(e) => setFromProject(e.target.checked)}
/>
session
</label>
) : (
<span
className="chat-hint tip"
data-tip="辦公室對話有自己的 session,每則訊息自動以 --resume 延續,每則訊息逐則接力。"
>
</span>
)}
<button className="btn assign-btn" onClick={send} disabled={busy || !input.trim()}>
{busy ? "執行中…" : "送出"}
</button>
</div>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { useEffect } from "react";
import { STATUS_COLORS, STATUS_LABELS } from "@/lib/constants";
import { generateAppearance } from "@/lib/appearance";
import { useOfficeStore } from "@/store/office-store";
import { ChatConversation } from "./ChatConversation";
import { Pawn } from "./Pawn";
/** Full-screen chat overlay for the selected project agent. */
export function ChatFullscreen() {
const agent = useOfficeStore((s) => (s.selectedAgentId ? s.agents.get(s.selectedAgentId) : undefined));
const setChatFullscreen = useOfficeStore((s) => s.setChatFullscreen);
// Esc returns to the office.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setChatFullscreen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [setChatFullscreen]);
if (!agent) return null;
return (
<div className="chat-full-overlay">
<div className="chat-full-card">
<div className="chat-full-topbar">
<div className="chat-full-who">
<svg viewBox="-30 -42 60 68" width="42" height="48">
<Pawn appearance={generateAppearance(agent.id)} pose="stand" motion="none" />
</svg>
<div>
<h2>{agent.name}</h2>
<span
className="status-chip"
style={{
backgroundColor: `${STATUS_COLORS[agent.status]}22`,
color: STATUS_COLORS[agent.status],
}}
>
{STATUS_LABELS[agent.status]}
</span>
<code className="model-tag">{agent.model}</code>
</div>
</div>
<button className="btn" onClick={() => setChatFullscreen(false)} title="縮回辦公室 (Esc)">
</button>
</div>
<ChatConversation agentId={agent.id} variant="full" />
</div>
</div>
);
}
+4 -88
View File
@@ -1,98 +1,12 @@
import { useEffect, useRef, useState } from "react";
import { STATUS_COLORS, STATUS_LABELS } from "@/lib/constants"; import { STATUS_COLORS, STATUS_LABELS } from "@/lib/constants";
import { generateAppearance } from "@/lib/appearance"; import { generateAppearance } from "@/lib/appearance";
import { useOfficeStore } from "@/store/office-store"; import { useOfficeStore } from "@/store/office-store";
import { getDirector } from "@/sim/runtime"; import { getDirector } from "@/sim/runtime";
import { resetChat, sendChat } from "@/gateway/live"; import { ChatConversation } from "./ChatConversation";
import { Pawn } from "./Pawn"; import { Pawn } from "./Pawn";
const ROLE_LABELS = { lead: "Lead Agent", agent: "Agent", subagent: "Subagent" } as const; const ROLE_LABELS = { lead: "Lead Agent", agent: "Agent", subagent: "Subagent" } as const;
/** LIVE mode: two-way chat with this project — each message runs headless, stitched with --resume. */
function ChatPanel({ agentId }: { agentId: string }) {
const thread = useOfficeStore((s) => s.chats.get(agentId));
const [input, setInput] = useState("");
const [fromProject, setFromProject] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const busy = thread?.busy ?? false;
const messages = thread?.messages ?? [];
const started = messages.length > 0;
useEffect(() => {
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
}, [messages.length, busy]);
const send = () => {
const text = input.trim();
if (!text || busy) return;
sendChat(agentId, text, fromProject);
setInput("");
};
return (
<div className="chat-panel">
<div className="chat-head">
<h3></h3>
{started && (
<button className="link-btn" onClick={() => resetChat(agentId)} disabled={busy}>
🔄
</button>
)}
</div>
{started && (
<div className="chat-list" ref={listRef}>
{messages.map((m, i) => (
<div key={i} className={`chat-msg ${m.role}`}>
{m.text}
</div>
))}
{busy && <div className="chat-msg pending"> ,</div>}
</div>
)}
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={started ? "繼續對話…(Enter 送出)" : "跟這個專案的 Codex 對話…(Enter 送出)"}
rows={2}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
}}
/>
<div className="task-composer-row">
{!started ? (
<label
className="tip"
data-tip="第一句話從該專案最近一段「已結束」的 session 接續(codex exec resume)。注意:無法插入正在終端機進行中的互動對話。"
>
<input
type="checkbox"
checked={fromProject}
onChange={(e) => setFromProject(e.target.checked)}
/>
session
</label>
) : (
<span
className="chat-hint tip"
data-tip="辦公室對話有自己的 session,每則訊息自動以 --resume 延續,每則訊息逐則接力。"
>
</span>
)}
<button className="btn assign-btn" onClick={send} disabled={busy || !input.trim()}>
{busy ? "執行中…" : "送出"}
</button>
</div>
</div>
);
}
export function SidePanel() { export function SidePanel() {
const selectedAgentId = useOfficeStore((s) => s.selectedAgentId); const selectedAgentId = useOfficeStore((s) => s.selectedAgentId);
const agent = useOfficeStore((s) => (s.selectedAgentId ? s.agents.get(s.selectedAgentId) : undefined)); const agent = useOfficeStore((s) => (s.selectedAgentId ? s.agents.get(s.selectedAgentId) : undefined));
@@ -186,7 +100,9 @@ export function SidePanel() {
</button> </button>
)} )}
{agent.role !== "subagent" && mode === "live" && <ChatPanel agentId={agent.id} />} {agent.role !== "subagent" && mode === "live" && (
<ChatConversation agentId={agent.id} variant="panel" />
)}
</div> </div>
) : ( ) : (
<div className="panel-hint"> <div className="panel-hint">
+7 -1
View File
@@ -38,6 +38,8 @@ interface OfficeState {
mode: "sim" | "live"; mode: "sim" | "live";
/** side panel visibility (collapse to maximise the office view) */ /** side panel visibility (collapse to maximise the office view) */
panelOpen: boolean; panelOpen: boolean;
/** LIVE-mode chat expanded to a full-screen overlay */
chatFullscreen: boolean;
/** simulation clock, seconds */ /** simulation clock, seconds */
clock: number; clock: number;
@@ -47,6 +49,7 @@ interface OfficeState {
setSpeed: (s: number) => void; setSpeed: (s: number) => void;
setMode: (m: "sim" | "live") => void; setMode: (m: "sim" | "live") => void;
togglePanel: () => void; togglePanel: () => void;
setChatFullscreen: (v: boolean) => void;
clearWorld: () => void; clearWorld: () => void;
chatAppend: (agentId: string, msg: ChatMessage) => void; chatAppend: (agentId: string, msg: ChatMessage) => void;
@@ -124,9 +127,10 @@ export const useOfficeStore = create<OfficeState>()((set, get) => ({
speed: 1, speed: 1,
mode: "sim", mode: "sim",
panelOpen: savedPanel !== "0", panelOpen: savedPanel !== "0",
chatFullscreen: false,
clock: 0, clock: 0,
selectAgent: (id) => set({ selectedAgentId: id }), selectAgent: (id) => set({ selectedAgentId: id, chatFullscreen: id === null ? false : get().chatFullscreen }),
setTheme: (t) => { setTheme: (t) => {
savePref("office-theme", t); savePref("office-theme", t);
set({ theme: t }); set({ theme: t });
@@ -142,6 +146,7 @@ export const useOfficeStore = create<OfficeState>()((set, get) => ({
savePref("office-panel", state.panelOpen ? "0" : "1"); savePref("office-panel", state.panelOpen ? "0" : "1");
return { panelOpen: !state.panelOpen }; return { panelOpen: !state.panelOpen };
}), }),
setChatFullscreen: (v) => set({ chatFullscreen: v }),
clearWorld: () => clearWorld: () =>
set({ set({
agents: new Map(), agents: new Map(),
@@ -150,6 +155,7 @@ export const useOfficeStore = create<OfficeState>()((set, get) => ({
events: [], events: [],
chats: new Map(), chats: new Map(),
selectedAgentId: null, selectedAgentId: null,
chatFullscreen: false,
}), }),
chatAppend: (agentId, msg) => chatAppend: (agentId, msg) =>
+82 -1
View File
@@ -372,7 +372,88 @@
cursor: default; cursor: default;
} }
.chat-list { .chat-head-actions {
display: flex;
align-items: center;
gap: 12px;
}
.chat-head .link-btn:disabled {
opacity: 0.4;
cursor: default;
}
/* ── full-screen chat overlay ── */
.chat-full-overlay {
position: fixed;
inset: 0;
z-index: 60;
background: color-mix(in srgb, var(--bg) 82%, transparent);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
animation: card-in 0.2s ease-out;
}
.chat-full-card {
width: min(860px, 100%);
height: min(88vh, 900px);
display: flex;
flex-direction: column;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
overflow: hidden;
padding: 18px 20px 20px;
}
.chat-full-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-bottom: 14px;
border-bottom: 1px solid var(--border);
}
.chat-full-who {
display: flex;
align-items: center;
gap: 12px;
}
.chat-full-who svg {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
flex-shrink: 0;
}
.chat-full-who h2 {
font-family: "Sora", system-ui, sans-serif;
font-size: 20px;
font-weight: 600;
margin-bottom: 4px;
}
.chat-full-who .model-tag {
margin-left: 8px;
}
/* the shared conversation fills the full-screen card */
.chat-panel-full {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
margin-top: 14px;
}
.chat-panel-full .chat-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;