diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.css b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.css new file mode 100644 index 0000000000..4a64b1ab5c --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.css @@ -0,0 +1,22 @@ +.attached-agent-conversations {margin: 12px 24px;} +.attached-conversation-entry {font: inherit; padding: 8px 12px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); color: var(--pw-text); cursor: pointer;} +.attached-conversation-dialog {position: fixed; inset: 0; margin: auto; width: min(960px, calc(100vw - 24px)); max-height: calc(100dvh - 48px); padding: 0; border: 1px solid var(--pw-line); border-radius: 12px; background: var(--pw-card); color: var(--pw-text); font: inherit;} +.attached-conversation-dialog[open] {display: flex; flex-direction: column;} +.attached-conversation-dialog::backdrop {background: rgb(0 0 0 / 32%);} +.attached-conversation-heading {display: flex; align-items: center; justify-content: space-between; padding: 16px; gap: 16px; border-bottom: 1px solid var(--pw-line);} +.attached-conversation-heading h2 {margin: 0; font-size: var(--text-heading);} +.attached-conversation-heading button {font: inherit; padding: 8px 12px; color: inherit; background: var(--pw-card); border: 1px solid var(--pw-line); border-radius: 6px;} +.attached-conversation-content {padding: 0 16px 16px; font-size: var(--text-body); min-height: 0; overflow: auto;} +.attached-conversation-content > p {color: var(--pw-text);} +.attached-conversation-content label {display: grid; gap: 8px; margin-block: 12px;} +.attached-conversation-content select, .attached-conversation-content textarea {font: inherit; padding: 8px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); color: var(--pw-text);} +.attached-conversation-content textarea {min-height: 88px; width: 100%; resize: vertical;} +.attached-conversation-content button {font: inherit; padding: 8px 12px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); color: var(--pw-text); cursor: pointer;} +.attached-conversation-content button:disabled, .attached-conversation-content button[aria-disabled="true"] {opacity: .5; cursor: default;} +.attached-conversation-content .attached-conversation-open {border: 0; padding: 0; border-radius: 0; background: transparent; text-decoration: underline; text-underline-offset: 3px;} +.attached-conversation-messages {max-height: min(40vh, 360px); overflow: auto; overscroll-behavior: contain; margin-block: 16px;} +.attached-conversation-messages article {padding-block: 16px; border-top: 1px solid var(--pw-line); overflow-wrap: anywhere;} +.attached-conversation-messages header {display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline;} +.attached-conversation-messages time {font-size: var(--text-caption); color: var(--pw-muted);} +.attached-conversation-messages [data-role="user"] {padding-inline: 12px; background: var(--pw-hover); border-radius: 6px;} +@media (max-width: 640px) {.attached-agent-conversations {margin: 12px;} .attached-conversation-messages {max-height: 48vh;}} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx new file mode 100644 index 0000000000..035239bdcb --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx @@ -0,0 +1,190 @@ +import {useEffect, useRef, useState} from "react"; +import {MarkdownText} from "./markdown"; +import "./attached-agent-conversations.css"; + +type Binding = {agent_id: string; thread_id: string; binding_ref: string}; +type Message = {id: string; role: "user" | "assistant"; text: string; truncated: boolean; timestamp: string | null}; +type Snapshot = {messages?: Message[]; before?: number | null; source_ref?: string; history_size?: number; revision: string; unchanged?: boolean; connected?: boolean; can_reply?: boolean; activity: {state: string}}; +type Submission = {operation_id: string; text: string; status?: string}; + +async function request(url: string, options?: RequestInit): Promise { + const response = await fetch(url, {...options, signal: AbortSignal.timeout(6_000)}); + const value = await response.json(); + if (!response.ok || value.ok !== true) throw new Error(value.error || "Conversation unavailable"); + return value as T; +} + +/** Owner-only original conversation. It never changes the selected executor. */ +export function AttachedAgentConversations({goalId, zh}: {goalId: string; zh: boolean}) { + const [open, setOpen] = useState(false); + const [agents, setAgents] = useState([]); + const [selected, setSelected] = useState(""); + const [messages, setMessages] = useState([]); + const [snapshot, setSnapshot] = useState(null); + const [before, setBefore] = useState(null); + const [error, setError] = useState(""); + const [recoveryError, setRecoveryError] = useState(""); + const [draft, setDraft] = useState(""); + const [submission, setSubmission] = useState(null); + const [sending, setSending] = useState(false); + const [loadingOlder, setLoadingOlder] = useState(false); + const [opening, setOpening] = useState(false); + const [openRequested, setOpenRequested] = useState(false); + const [openError, setOpenError] = useState(""); + const [refresh, setRefresh] = useState(0); + const generation = useRef(0); + const receiptGeneration = useRef(0); + const revision = useRef(""); + const history = useRef<{source: string; size: number} | null>(null); + const transcript = useRef(null); + const dialog = useRef(null); + useEffect(() => { + if (open && !dialog.current?.open) dialog.current?.showModal(); + else if (!open && dialog.current?.open) dialog.current.close(); + }, [open]); + const binding = agents.find(agent => agent.agent_id === selected); + const awaitingReceipt = Boolean(submission?.status && submission.status !== "delivered"); + const cacheKey = binding ? `loopx-attached-input:${goalId}:${binding.binding_ref}` : ""; + const query = binding ? new URLSearchParams({goal_id: goalId, agent_id: binding.agent_id, binding_ref: binding.binding_ref}) : null; + + useEffect(() => { + let active = true; + setAgents([]); setSelected(""); setMessages([]); setSnapshot(null); setError(""); + if (open) void request<{agents: Binding[]}>(`/api/chat/codex-attached?${new URLSearchParams({goal_id: goalId})}`) + .then(value => {if (active) {setAgents(value.agents); setSelected(value.agents[0]?.agent_id ?? "");}}) + .catch(failure => {if (active) setError(String(failure));}); + return () => {active = false;}; + }, [goalId, open, refresh]); + + useEffect(() => { + const current = ++generation.current; + revision.current = ""; + history.current = null; + setOpening(false); setOpenRequested(false); setOpenError(""); + setMessages([]); setSnapshot(null); setBefore(null); setDraft(""); setSubmission(null); setSending(false); setLoadingOlder(false); setError(""); setRecoveryError(""); + if (!binding || !open) return; + try { + const stored = sessionStorage.getItem(cacheKey); + if (stored) { + const pending = JSON.parse(stored) as Submission; + if (!pending || typeof pending.operation_id !== "string" || typeof pending.text !== "string" || !["pending", "delivered"].includes(pending.status ?? "")) throw new Error("Invalid recovery record"); + setDraft(pending.status === "delivered" ? "" : pending.text); setSubmission(pending); + if (pending.status === "pending") void readReceipt(pending, current); + } + } catch {setRecoveryError(zh ? "浏览器回复恢复记录不可读,暂不能安全发送。请先在原 App 核对。" : "Reply recovery is unreadable; check the original App before clearing it.");} + let timer: ReturnType; + async function read() { + const params = new URLSearchParams(query!); + if (revision.current) params.set("revision", revision.current); + try { + const next = await request(`/api/chat/codex-attached?${params}`); + if (current !== generation.current) return; + const follow = !transcript.current || transcript.current.scrollHeight - transcript.current.scrollTop - transcript.current.clientHeight < 64; + revision.current = next.revision; + if (!next.unchanged) { + const reset = !history.current || history.current.source !== next.source_ref || (next.history_size ?? 0) < history.current.size; + history.current = {source: next.source_ref ?? "", size: next.history_size ?? 0}; + setMessages(previous => [...new Map([...(reset ? [] : previous), ...next.messages ?? []].map(message => [message.id, message])).values()].sort((a,b) => Number(a.id) - Number(b.id)).slice(-1000)); + if (reset) setBefore(next.before ?? null); + } + setSnapshot(previous => ({...previous, ...next})); setError(""); + if (follow) requestAnimationFrame(() => transcript.current?.scrollTo({top: transcript.current.scrollHeight})); + } catch (failure) {if (current === generation.current) {setError(String(failure)); setSnapshot(null);}} + finally {if (current === generation.current) timer = setTimeout(read, 1000);} + } + void read(); + return () => {generation.current++; clearTimeout(timer);}; + }, [goalId, binding?.binding_ref, open]); + + async function openOriginal() { + if (!binding || opening) return; + const current = generation.current; + setOpening(true); setOpenRequested(false); setOpenError(""); + try { + await request<{requested: true}>("/api/chat/codex-attached/open", {method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({goal_id: goalId, agent_id: binding.agent_id, binding_ref: binding.binding_ref})}); + if (current === generation.current) setOpenRequested(true); + } catch (failure) {if (current === generation.current) setOpenError(String(failure));} + finally {if (current === generation.current) setOpening(false);} + } + + async function older() { + if (before === null || !query || loadingOlder) return; + const current = generation.current; + setLoadingOlder(true); + const params = new URLSearchParams(query); params.set("before", String(before)); + try { + const next = await request(`/api/chat/codex-attached?${params}`); + if (current === generation.current) { + if (!history.current || history.current.source !== next.source_ref || (next.history_size ?? 0) < history.current.size) throw new Error(zh ? "原会话历史已更新,请重新连接。" : "Original history changed; reconnect to read it."); + setMessages(previous => [...new Map([...(next.messages ?? []), ...previous].map(row => [row.id, row])).values()].sort((a,b) => Number(a.id) - Number(b.id)).slice(0,1000)); + setBefore(next.before ?? null); + } + } catch (failure) {if (current === generation.current) setError(String(failure));} + finally {if (current === generation.current) setLoadingOlder(false);} + } + + async function send() { + if (!binding || sending || error || recoveryError || !snapshot?.can_reply || !draft.trim() || awaitingReceipt) return; + const current = generation.current; + const pending = {operation_id: crypto.randomUUID(), text: draft, status: "pending"}; + try {sessionStorage.setItem(cacheKey, JSON.stringify(pending));} + catch {setError(zh ? "无法保存回复回执,未发送。" : "Cannot save reply recovery; nothing was sent."); return;} + receiptGeneration.current++; + setSubmission(pending); setSending(true); + try { + const result = await request<{status: string}>("/api/chat/codex-attached", {method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({operation_id: pending.operation_id, text: pending.text, goal_id: goalId, agent_id: binding.agent_id, binding_ref: binding.binding_ref})}); + if (current === generation.current) { + const delivered = {...pending, status: result.status}; + sessionStorage.setItem(cacheKey, JSON.stringify(delivered)); setSubmission(delivered); + if (result.status === "delivered") setDraft(""); + else void readReceipt(pending, current); + revision.current = ""; + } + } catch (failure) {if (current === generation.current) {setError(String(failure)); void readReceipt(pending, current);}} + finally {if (current === generation.current) setSending(false);} + } + async function readReceipt(pending = submission, current = generation.current) { + // Recover from existing evidence only. Never retry the native effect. + if (!query || !pending || current !== generation.current) return; + const receiptVersion = receiptGeneration.current; + const params = new URLSearchParams(query); params.set("operation_id", pending.operation_id); + try { + const receipt = await request<{status: string}>(`/api/chat/codex-attached?${params}`); + if (current === generation.current && receiptVersion === receiptGeneration.current) { + const next = {...pending, status: receipt.status}; + sessionStorage.setItem(cacheKey, JSON.stringify(next)); setSubmission(next); + setRecoveryError(""); + if (receipt.status === "delivered") {receiptGeneration.current++; setDraft("");} + } + } catch (failure) {if (current === generation.current && receiptVersion === receiptGeneration.current) setRecoveryError(String(failure));} + } + function nextReply() { + try {sessionStorage.removeItem(cacheKey); receiptGeneration.current++; setSubmission(null); setDraft(""); setError(""); setRecoveryError("");} + catch {setError(zh ? "无法清理回复回执。" : "Reply recovery could not be cleared.");} + } + return
+ + setOpen(false)}> +

{zh ? "Codex App 原会话" : "Original Codex App conversations"}

+
+

{zh ? "每秒同步原会话已保存的消息;逐字流和审批仍在 Codex App。" : "Saved messages sync every second; token streaming and approvals remain in Codex App."}

+ {agents.length ? : !error ?

{zh ? "此 Goal 暂无已绑定的本机 Codex App 会话。" : "This Goal has no bound local Codex App conversation."}

: null} + {recoveryError ?

{recoveryError}

: null} + {error ?

{error}

: null} + {binding ? <> +

{!snapshot ? (zh ? "正在连接或连接不可用" : "Connecting or unavailable") : snapshot.can_reply ? (zh ? "原会话可回复" : "Original conversation accepts replies") : snapshot.activity.state === "turn_open" ? (zh ? "原会话有未结束执行;可在原 App 中追加输入" : "The original conversation has an unfinished turn; add input in the original App") : (zh ? "历史可读;回复连接或执行状态尚未确认" : "History available; reply connection or execution state unconfirmed")}

+ + {openRequested ?

{zh ? "已请求打开原会话,请在 Codex App 中查看。" : "Requested opening the original conversation; check Codex App."}

: null} + {openError ?

{openError}

: null} + {before !== null ? : null} +
+ {messages.map(message =>
{message.role === "user" ? (zh ? "你" : "You") : binding.agent_id} {message.timestamp ? : null}
{message.truncated ?

{zh ? "此消息已截短;完整内容见原 App。" : "Message shortened; open the original App for full content."}

: null}
)} + {!messages.length && snapshot ?

{zh ? "当前有界历史窗口内没有可显示的消息,可读取更早记录。" : "No displayable messages in this bounded window; read earlier records."}

: null} +
+ {submission?.status ?

{submission.status === "delivered" ? (zh ? "已由原 App 接纳;等待 Agent 结果。" : "Accepted by the original App; awaiting the Agent result.") : (zh ? "投递尚未确认,请先在原 App 核对,不会自动重发。" : "Delivery unconfirmed; check the original App. No automatic resend.")} {submission.status === "pending" ? : null}{awaitingReceipt ? : null}

: null} + {!awaitingReceipt ?
{event.preventDefault(); void send();}}>