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 index c41ae101b3..4825ebe08f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.css @@ -54,6 +54,12 @@ .attached-conversation-messages summary > time {margin-left: 8px;} .attached-tool-metadata {font-size: var(--text-caption); margin-top: 8px;} .attached-conversation-messages .attached-tool-source {font-size: var(--text-caption);} +.attached-conversation-messages strong:has(> .attached-activity-mark) {display: inline-flex; align-items: center; gap: 8px;} +.attached-activity-mark {position: relative; display: inline-block; flex: 0 0 12px; width: 12px; height: 12px; border: 1px solid var(--pw-muted); border-radius: 50%; color: var(--pw-blue-ink);} +.attached-activity-mark[data-active] {border-color: color-mix(in srgb, currentColor 28%, transparent);} +.attached-activity-mark[data-active]::before {content: ""; position: absolute; inset: 3px; border-radius: 50%; background: currentColor;} +.attached-activity-mark[data-active]::after {content: ""; position: absolute; inset: -1px; border: 1px solid transparent; border-top-color: currentColor; border-radius: 50%; animation: pw-spin 1s linear infinite;} +@media (prefers-reduced-motion: reduce) {.attached-activity-mark[data-active]::after {animation: none;}} @media (max-width: 640px) { .goal-agent-work-page form {flex-wrap: wrap;} .goal-agent-work-page form label {flex-basis: 100%;} 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 index 909c9d1d86..e35638b492 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx @@ -6,6 +6,9 @@ type Binding = {agent_id: string; thread_id: string; binding_ref: string}; type Message = ConversationRecord; type Snapshot = {messages?: Message[]; before?: number | null; after?: number; has_more?: boolean; reset?: boolean; 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}; +type LiveRecords = {identity: string; records: Message[]}; +type LiveObservation = {kind: "records"; revision: number; records: Message[]} | + {kind: "unavailable"; retry: boolean}; class ConversationRequestError extends Error { constructor(message: string, readonly unsubmittedOperation?: string, readonly httpStatus?: number) {super(message);} @@ -95,6 +98,9 @@ export function AttachedAgentConversations({goalId, zh, targetAgentId}: { const [refresh, setRefresh] = useState(0); const [readingHistory, setReadingHistory] = useState(false); const [unread, setUnread] = useState(false); + const [liveView, setLiveView] = useState({identity: "", records: []}); + const [liveUnavailable, setLiveUnavailable] = useState(false); + const latestLive = useRef({identity: "", records: []}); const following = useRef(true); const after = useRef(null); const historyEpoch = useRef(0); @@ -108,8 +114,41 @@ export function AttachedAgentConversations({goalId, zh, targetAgentId}: { 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 liveEnabled = Boolean(snapshot?.connected && snapshot.activity.state !== "idle"); + const liveIdentity = `${goalId}:${targetAgentId ?? ""}:${binding?.agent_id ?? ""}:${binding?.binding_ref ?? ""}`; + const liveRecords = liveView.identity === liveIdentity ? liveView.records : []; + // Frozen history is still readable, but must not look like current work. + // Connection/registration alone is never an activity observation. + const liveActive = liveEnabled && !liveUnavailable && !readingHistory; const query = binding ? new URLSearchParams({goal_id: goalId, agent_id: binding.agent_id, binding_ref: binding.binding_ref}) : null; + useEffect(() => { + let active = true; + latestLive.current = {identity: "", records: []}; setLiveView(latestLive.current); setLiveUnavailable(false); + if (!binding || !liveEnabled) return; + const source = new EventSource(`/api/chat/codex-attached/live?${query}`); + const unavailable = () => {latestLive.current = {identity: "", records: []}; setLiveView(latestLive.current); setLiveUnavailable(true);}; + source.addEventListener("observation", event => { + if (!active) return; + try { + const value = JSON.parse((event as MessageEvent).data) as LiveObservation; + if (value.kind === "records" && Array.isArray(value.records)) { + latestLive.current = {identity: liveIdentity, records: value.records}; setLiveUnavailable(false); + if (following.current) { + const follow = !transcript.current || transcript.current.scrollHeight - transcript.current.scrollTop - transcript.current.clientHeight < 64; + setLiveView(latestLive.current); + if (follow) requestAnimationFrame(() => {if (active && following.current) transcript.current?.scrollTo({top: transcript.current.scrollHeight});}); + } else setUnread(true); + } else if (value.kind === "unavailable") { + unavailable(); + if (!value.retry) source.close(); + } else {source.close(); unavailable();} + } catch {source.close(); unavailable();} + }); + source.onerror = () => {if (active) unavailable();}; + return () => {active = false; source.close();}; + }, [liveIdentity, liveEnabled]); + useEffect(() => { let active = true; let timer: ReturnType; @@ -173,7 +212,7 @@ export function AttachedAgentConversations({goalId, zh, targetAgentId}: { if (combined.length > 300) setBefore(Number(combined[combined.length - 300].id)); return combined.slice(-300); }); - if (reset) {setBefore(next.before ?? null); following.current = true; setReadingHistory(false); setUnread(false);} + if (reset) {setBefore(next.before ?? null); resumeFollowing();} } else setUnread(true); } setSnapshot(previous => ({...previous, ...next})); setError(""); @@ -223,17 +262,24 @@ export function AttachedAgentConversations({goalId, zh, targetAgentId}: { return retained; }); if (!newer) requestAnimationFrame(() => {if (transcript.current) transcript.current.scrollTop += transcript.current.scrollHeight - previousHeight;}); - if (newer && !next.has_more) {following.current = true; setReadingHistory(false); setUnread(false);} + if (newer && !next.has_more) resumeFollowing(); setError(""); } } catch (failure) {if (current === generation.current) setError(String(failure));} finally {if (current === generation.current) setLoadingOlder(false);} } + // Every path that returns to the live tail publishes the buffered projection. + // Resuming while keeping the frozen draft would show stale work as current. + function resumeFollowing() { + following.current = true; setReadingHistory(false); setUnread(false); + setLiveView(latestLive.current); + } function pauseHistory() {following.current = false; setReadingHistory(true);} function latest() { historyEpoch.current++; after.current = null; history.current = null; revision.current = ""; - following.current = true; setReadingHistory(false); setUnread(false); setMessages([]); setBefore(null); + setMessages([]); setBefore(null); + resumeFollowing(); } async function loadContent(record: Message, offset: number): Promise { const params = new URLSearchParams(query!); @@ -307,7 +353,7 @@ export function AttachedAgentConversations({goalId, zh, targetAgentId}: { {error ?

{error}

: null} {sendNotice ?

{sendNotice}

: null} {binding ? <> -

{zh ? "Codex App · 已保存消息与工具记录自动更新" : "Codex App · saved messages and tools update automatically"}

{!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")}

+

Codex App

{!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} @@ -315,8 +361,12 @@ export function AttachedAgentConversations({goalId, zh, targetAgentId}: { {readingHistory ?
{zh ? "正在回看历史" : "Reading history"}{unread ? (zh ? " · 有新活动" : " · new activity") : ""}
: null}
{if (event.deltaY < 0) pauseHistory();}} onTouchMove={pauseHistory} onClick={pauseHistory} onKeyDown={event => {if (["ArrowUp", "PageUp", "Home"].includes(event.key)) pauseHistory();}} aria-label={zh ? "原会话消息" : "Original conversation messages"}> {messages.map(message => )} - {!messages.length && snapshot ?

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

: null} + {liveRecords.length ?
+ {liveRecords.map(record => )} +
: null} + {!messages.length && !liveRecords.length && snapshot ?

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

: null}
+ {liveUnavailable ?

{zh ? "实时观察暂不可用,已保存的消息与工具记录仍会自动更新。" : "Live observation is unavailable; saved messages and tools still update automatically."}

: 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();}}>