對話可切全螢幕,亦可縮回側邊面板回到辦公室
摘要: 與 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:
+33
-26
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user