對話可切全螢幕,亦可縮回側邊面板回到辦公室
摘要: LIVE 模式對話新增「⛶ 全螢幕」按鈕,展開為置中的全螢幕聊天卡片 (辦公室模糊在後);全螢幕內有「⊟ 回到辦公室」鈕、對話列有「縮小」, 按 Esc 亦可返回。 根本原因: 對話擠在 300px 側欄裡,長對話讀起來侷促,需要一個能專心對話的大畫面, 且要能隨時縮回辦公室繼續看小人。 影響: 新增純 UI 覆蓋層,不影響資料流;側欄與全螢幕共用同一對話元件, 狀態同步(同一 store thread)。取消選取或切換模式時自動關閉全螢幕。 修法: - 抽出 ChatConversation 共用元件(variant: panel | full) - ChatFullscreen 覆蓋層(頂欄 agent 資訊 + Esc 關閉) - store 新增 chatFullscreen / setChatFullscreen;selectAgent(null) 與 clearWorld 時歸零;App 依 chatFullscreen && live && 已選取 渲染覆蓋層 - styles:.chat-full-overlay 毛玻璃背景、卡片版面、full variant 撐滿高度 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { ChatFullscreen } from "./components/ChatFullscreen";
|
||||
import { FloorPlan } from "./components/FloorPlan";
|
||||
import { HeaderBar } from "./components/HeaderBar";
|
||||
import { SidePanel } from "./components/SidePanel";
|
||||
@@ -8,12 +9,17 @@ import { loadPreferredMode, useOfficeStore } from "./store/office-store";
|
||||
export default function App() {
|
||||
const theme = useOfficeStore((s) => s.theme);
|
||||
const panelOpen = useOfficeStore((s) => s.panelOpen);
|
||||
const chatFullscreen = useOfficeStore((s) => s.chatFullscreen);
|
||||
const selectedAgentId = useOfficeStore((s) => s.selectedAgentId);
|
||||
const mode = useOfficeStore((s) => s.mode);
|
||||
|
||||
useEffect(() => {
|
||||
startRuntime();
|
||||
if (loadPreferredMode() === "live") setMode("live");
|
||||
}, []);
|
||||
|
||||
const showFullscreenChat = chatFullscreen && mode === "live" && selectedAgentId !== null;
|
||||
|
||||
return (
|
||||
<div className={`app ${theme}`}>
|
||||
<HeaderBar />
|
||||
@@ -21,6 +27,7 @@ export default function App() {
|
||||
<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 送出)" : "跟這個專案的 Claude 對話…(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 接續(--resume)。注意:無法插入正在終端機進行中的互動對話。"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fromProject}
|
||||
onChange={(e) => setFromProject(e.target.checked)}
|
||||
/>
|
||||
接續專案最近 session
|
||||
</label>
|
||||
) : (
|
||||
<span
|
||||
className="chat-hint tip"
|
||||
data-tip="辦公室對話有自己的 session,每則訊息自動以 --resume 延續,像 claudecodeui 一樣逐則接力。"
|
||||
>
|
||||
✓ 對話自動延續
|
||||
</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 { generateAppearance } from "@/lib/appearance";
|
||||
import { useOfficeStore } from "@/store/office-store";
|
||||
import { getDirector } from "@/sim/runtime";
|
||||
import { resetChat, sendChat } from "@/gateway/live";
|
||||
import { ChatConversation } from "./ChatConversation";
|
||||
import { Pawn } from "./Pawn";
|
||||
|
||||
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 送出)" : "跟這個專案的 Claude 對話…(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 接續(--resume)。注意:無法插入正在終端機進行中的互動對話。"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fromProject}
|
||||
onChange={(e) => setFromProject(e.target.checked)}
|
||||
/>
|
||||
接續專案最近 session
|
||||
</label>
|
||||
) : (
|
||||
<span
|
||||
className="chat-hint tip"
|
||||
data-tip="辦公室對話有自己的 session,每則訊息自動以 --resume 延續,像 claudecodeui 一樣逐則接力。"
|
||||
>
|
||||
✓ 對話自動延續
|
||||
</span>
|
||||
)}
|
||||
<button className="btn assign-btn" onClick={send} disabled={busy || !input.trim()}>
|
||||
{busy ? "執行中…" : "送出"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidePanel() {
|
||||
const selectedAgentId = useOfficeStore((s) => s.selectedAgentId);
|
||||
const agent = useOfficeStore((s) => (s.selectedAgentId ? s.agents.get(s.selectedAgentId) : undefined));
|
||||
@@ -186,7 +100,9 @@ export function SidePanel() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{agent.role !== "subagent" && mode === "live" && <ChatPanel agentId={agent.id} />}
|
||||
{agent.role !== "subagent" && mode === "live" && (
|
||||
<ChatConversation agentId={agent.id} variant="panel" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="panel-hint">
|
||||
|
||||
@@ -38,6 +38,8 @@ interface OfficeState {
|
||||
mode: "sim" | "live";
|
||||
/** side panel visibility (collapse to maximise the office view) */
|
||||
panelOpen: boolean;
|
||||
/** LIVE-mode chat expanded to a full-screen overlay */
|
||||
chatFullscreen: boolean;
|
||||
/** simulation clock, seconds */
|
||||
clock: number;
|
||||
|
||||
@@ -47,6 +49,7 @@ interface OfficeState {
|
||||
setSpeed: (s: number) => void;
|
||||
setMode: (m: "sim" | "live") => void;
|
||||
togglePanel: () => void;
|
||||
setChatFullscreen: (v: boolean) => void;
|
||||
clearWorld: () => void;
|
||||
|
||||
chatAppend: (agentId: string, msg: ChatMessage) => void;
|
||||
@@ -124,9 +127,10 @@ export const useOfficeStore = create<OfficeState>()((set, get) => ({
|
||||
speed: 1,
|
||||
mode: "sim",
|
||||
panelOpen: savedPanel !== "0",
|
||||
chatFullscreen: false,
|
||||
clock: 0,
|
||||
|
||||
selectAgent: (id) => set({ selectedAgentId: id }),
|
||||
selectAgent: (id) => set({ selectedAgentId: id, chatFullscreen: id === null ? false : get().chatFullscreen }),
|
||||
setTheme: (t) => {
|
||||
savePref("office-theme", t);
|
||||
set({ theme: t });
|
||||
@@ -142,6 +146,7 @@ export const useOfficeStore = create<OfficeState>()((set, get) => ({
|
||||
savePref("office-panel", state.panelOpen ? "0" : "1");
|
||||
return { panelOpen: !state.panelOpen };
|
||||
}),
|
||||
setChatFullscreen: (v) => set({ chatFullscreen: v }),
|
||||
clearWorld: () =>
|
||||
set({
|
||||
agents: new Map(),
|
||||
@@ -150,6 +155,7 @@ export const useOfficeStore = create<OfficeState>()((set, get) => ({
|
||||
events: [],
|
||||
chats: new Map(),
|
||||
selectedAgentId: null,
|
||||
chatFullscreen: false,
|
||||
}),
|
||||
|
||||
chatAppend: (agentId, msg) =>
|
||||
|
||||
@@ -356,11 +356,97 @@
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.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: "Fraunces", Georgia, 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 {
|
||||
flex: 1;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.chat-panel-full .chat-msg {
|
||||
max-width: 74%;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.chat-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user