110 lines
3.5 KiB
TypeScript
110 lines
3.5 KiB
TypeScript
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>
|
||
|
|
);
|
||
|
|
}
|