From 591b16242198bbc884151939c406d53e72588812 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:34:55 +0800 Subject: [PATCH 01/22] fix(chat): separate model identity, request provenance and work activity Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- apps/presentation/dashboard/package.json | 2 +- apps/presentation/dashboard/src/data/chat.ts | 6 +- .../presentation/dashboard/src/data/status.ts | 2 + .../dashboard/src/data/turn-steps.ts | 21 ++- .../attached-agent-conversations.css | 13 +- .../attached-agent-conversations.tsx | 140 ++++++++++++++---- .../attached-conversation-record.tsx | 53 ++++++- .../attention-detail-card.tsx | 13 ++ .../personal-workspace/attention-details.ts | 5 +- .../capability-configuration-fields.tsx | 4 +- .../capability-localization.ts | 13 ++ .../cards/attention-row.tsx | 3 +- .../personal-workspace/channel-header.tsx | 34 ++++- .../personal-workspace/context-drawer.tsx | 7 +- .../personal-workspace/goal-loopx-mode.tsx | 3 +- .../src/features/personal-workspace/i18n.tsx | 6 + .../machine-configuration-settings.tsx | 8 +- .../personal-workspace-model.ts | 4 +- .../personal-workspace-page.tsx | 11 +- .../personal-workspace/personal-workspace.css | 11 +- .../personal-workspace/turn-steps-view.tsx | 11 +- .../workspace-settings-page.tsx | 6 +- .../dashboard/src/views/dashboard-page.tsx | 12 +- loopx/capabilities/configuration_ui.py | 14 ++ .../machine_configuration/builtins.py | 2 + .../native_chat/machine_defaults.py | 85 +++++++++++ loopx/chat_agent.py | 12 ++ loopx/chat_providers.py | 7 +- loopx/chat_runtime.py | 60 ++++++-- loopx/chat_server.py | 10 +- loopx/chat_store.py | 21 +++ .../presentation/todo_request_content.ts | 8 +- loopx/control_plane/todos/todo_summary.py | 3 + .../control_plane/work_items/project_asset.py | 1 + loopx/presentation/codex_activity.py | 34 +++-- 35 files changed, 539 insertions(+), 106 deletions(-) create mode 100644 loopx/capabilities/native_chat/machine_defaults.py diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index 93df623143..5da2ab1827 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -61,7 +61,7 @@ "smoke:task-board-scroll": "node smoke/task-board-scroll-smoke.mjs", "smoke:usage-progress": "tsc --ignoreConfig --target ES2022 --module ESNext --moduleResolution Bundler --allowImportingTsExtensions --types vite/client --resolveJsonModule --esModuleInterop --jsx react-jsx --skipLibCheck --strict --noEmit smoke/usage-progress-smoke.ts src/features/personal-workspace/personal-workspace-model.ts && vite build --ssr smoke/usage-progress-smoke.ts --outDir node_modules/.cache/loopx-usage-progress-smoke --emptyOutDir && node node_modules/.cache/loopx-usage-progress-smoke/usage-progress-smoke.js", "smoke:action-review-plan": "tsc --noEmit --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --skipLibCheck --strict smoke/action-review-plan-smoke.ts src/vite-env.d.ts && vite build --ssr smoke/action-review-plan-smoke.ts --outDir node_modules/.cache/loopx-action-review-plan --emptyOutDir && node node_modules/.cache/loopx-action-review-plan/action-review-plan-smoke.js", - "smoke:attention-details": "tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --resolveJsonModule --esModuleInterop --jsx react-jsx --skipLibCheck --strict --outDir /tmp/loopx-attention-details-smoke smoke/attention-details-smoke.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-attention-details-smoke/apps/presentation/dashboard/smoke/attention-details-smoke.js", + "smoke:attention-details": "tsc --noEmit --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --allowImportingTsExtensions --resolveJsonModule --esModuleInterop --jsx react-jsx --skipLibCheck --strict --types vite/client smoke/attention-details-smoke.ts && vite build --ssr smoke/attention-details-smoke.ts --outDir node_modules/.cache/loopx-attention-details --emptyOutDir && node node_modules/.cache/loopx-attention-details/attention-details-smoke.js", "smoke:delivery-review": "tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck --strict --rootDir src --outDir node_modules/.cache/delivery-review src/data/delivery-review.ts src/data/goal-work-map.ts src/data/goal-acceptance-observation.ts src/features/personal-workspace/delivery-review-copy.ts && node smoke/delivery-review-smoke.mjs", "smoke:team-artifact-comparison": "tsc --noEmit && vite build --ssr smoke/team-artifact-comparison-smoke.mjs --outDir node_modules/.cache/team-comparison --emptyOutDir && node node_modules/.cache/team-comparison/team-artifact-comparison-smoke.js", "smoke:team-report": "vite build --ssr smoke/team-report-smoke.mjs --outDir node_modules/.cache/team-report --emptyOutDir && node node_modules/.cache/team-report/team-report-smoke.js", diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 62fd4f375e..3f574173c7 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -749,9 +749,9 @@ export type AgentModelDiscovery = { model: { id: string; label: string; selected: string; options: AgentModelOption[] } | null; }; -export function discoverAgentModelOptions(agentId: string, signal?: AbortSignal) { +export function discoverAgentModelOptions(agentId: string, signal?: AbortSignal, goalId?: string) { return requestJson("/api/chat/agent-model-options", { - method: "POST", signal, body: JSON.stringify({agent_id: agentId}), + method: "POST", signal, body: JSON.stringify({agent_id: agentId, ...(goalId ? {goal_id: goalId} : {})}), }); } @@ -769,6 +769,8 @@ export type ChatSessionSummary = { project_ref?: string | null; agent_id: string; agent_model_id?: string | null; + agent_reasoning_effort?: string | null; + agent_service_tier?: string | null; adapter_kind: string; channel_id?: string; status: string; diff --git a/apps/presentation/dashboard/src/data/status.ts b/apps/presentation/dashboard/src/data/status.ts index 1ddfff26b2..301fcf817f 100644 --- a/apps/presentation/dashboard/src/data/status.ts +++ b/apps/presentation/dashboard/src/data/status.ts @@ -88,6 +88,8 @@ export const todoItemSchema = z.object({ watch_only: z.union([z.boolean(), z.string()]).optional().nullable(), task_domain: z.string().optional().nullable(), action_kind: z.string().optional().nullable(), + // Canonical author provenance, distinct from the Agent authorized to reply. + created_by: z.string().optional().nullable(), claimed_by: z.string().optional().nullable(), required_capabilities: z.array(z.string()).optional(), note: z.string().optional().nullable(), diff --git a/apps/presentation/dashboard/src/data/turn-steps.ts b/apps/presentation/dashboard/src/data/turn-steps.ts index 1433a765b3..382a3498bd 100644 --- a/apps/presentation/dashboard/src/data/turn-steps.ts +++ b/apps/presentation/dashboard/src/data/turn-steps.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -// Mirrors loopx/chat_activity.py. A step is what the executor did, already +// Mirrors loopx/presentation/codex_activity.py. A step is what the executor did, already // redacted by the adapter; "completed" means the host item ended, not that a // check passed. export const TURN_STEP_KINDS = ["reasoning", "command", "tool", "search", "file_change"] as const; @@ -15,6 +15,7 @@ const turnStepSchema = z.object({ title: z.string().max(200), verb: z.enum(TURN_COMMAND_VERBS).optional(), detail: z.string().max(4200).optional(), + reasoning_summary: z.literal(true).optional(), duration_ms: z.number().int().nonnegative().optional(), exit_code: z.number().int().optional(), count: z.number().int().nonnegative().optional(), @@ -25,7 +26,15 @@ export type TurnStep = z.infer; /** An unknown or malformed step is dropped; its legacy label still renders. */ export function parseTurnStep(value: unknown): TurnStep | null { const parsed = turnStepSchema.safeParse(value); - return parsed.success ? parsed.data : null; + if (!parsed.success) return null; + const step = parsed.data; + // Older events mixed raw reasoning and summaries. Keep their timing, but + // never present unqualified text as a public summary on historical replay. + if (step.kind === "reasoning" && !step.reasoning_summary) { + const { detail: _detail, ...safe } = step; + return { ...safe, title: "" }; + } + return step; } /** One row per host item: later events for the same id update it in place. */ @@ -34,7 +43,13 @@ export function mergeTurnStep(steps: readonly TurnStep[] | undefined, step: Turn const index = current.findIndex(item => item.id === step.id); if (index < 0) return [...current, step].slice(-TURN_STEP_LIMIT); const previous = current[index]; - const merged = { ...previous, ...step, ...(step.detail || !previous.detail ? {} : { detail: previous.detail }) }; + // A legacy unqualified update may not inherit a public-summary marker, nor + // may a later summary promote unqualified text retained by an older client. + if (step.kind === "reasoning" && !step.reasoning_summary) { + return current.map((item, position) => position === index ? step : item); + } + const previousDetail = previous.kind !== "reasoning" || previous.reasoning_summary ? previous.detail : undefined; + const merged = { ...previous, ...step, detail: step.detail || previousDetail }; return current.map((item, position) => position === index ? merged : item); } 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 4825ebe08f..156014f5e9 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 @@ -11,7 +11,7 @@ .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 {max-height: min(40vh, 360px); overflow: auto; overflow-anchor: none; overscroll-behavior: contain; margin-block: 16px; min-width: 0; scrollbar-gutter: stable;} .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);} @@ -22,7 +22,16 @@ .attached-conversation-messages details {margin-block: 0;} .attached-conversation-messages summary {cursor: pointer; min-height: 44px; align-content: center; color: var(--pw-text);} .attached-conversation-messages pre {font: var(--text-body) / var(--leading-prose) var(--font-mono); color: var(--pw-text); white-space: pre-wrap; overflow-wrap: anywhere; max-height: 24rem; overflow: auto;} -.attached-history-navigation {display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: var(--text-caption);} +.attached-history-status {min-height: 20px; color: var(--pw-muted); font-size: var(--text-caption); flex-shrink: 0;} +.attached-conversation-messages .attached-record-group {margin-block: 8px 16px; border: 1px solid var(--pw-line); border-radius: 8px; overflow-wrap: anywhere;} +.attached-conversation-messages .attached-record-group > summary {padding: 8px 12px; font-size: var(--text-caption); color: var(--pw-muted);} +.attached-record-count {font-weight: normal;} +.attached-record-warning {margin-inline-start: 8px; color: var(--pw-text);} +.attached-record-group-body {padding-inline: 12px;} +.attached-record-group-body article:first-child {border-top: 0;} +.attached-conversation-messages article[data-role="assistant"] {line-height: var(--leading-prose);} +.attached-conversation-messages article[data-role="assistant"] header {margin-bottom: 8px;} +.attached-conversation-messages .personal-md {min-width: 0; overflow-wrap: anywhere;} .personal-composer-wrap[hidden] {display: none;} .personal-channel-scroll[data-agent-work="true"] {padding-block: 0; overflow: hidden;} .personal-channel-scroll[data-agent-work="true"] > .personal-goal-view-panel:not([hidden]) {height: 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 e35638b492..a59a1e44b6 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 @@ -1,5 +1,5 @@ -import {useEffect, useRef, useState} from "react"; -import {AttachedConversationRecord, type ConversationRecord, type ContentPage} from "./attached-conversation-record"; +import {useEffect, useLayoutEffect, useRef, useState} from "react"; +import {AttachedConversationTimeline, type ConversationRecord, type ContentPage} from "./attached-conversation-record"; import "./attached-agent-conversations.css"; type Binding = {agent_id: string; thread_id: string; binding_ref: string}; @@ -43,8 +43,12 @@ function interruptedRead(failure: unknown): boolean { return failure instanceof TypeError || (failure instanceof DOMException && failure.name === "TimeoutError"); } +function retryableRead(failure: unknown): boolean { + return interruptedRead(failure) || (failure instanceof ConversationRequestError && (failure.httpStatus ?? 0) >= 500); +} + function readFailureMessage(failure: unknown, zh: boolean): string { - return interruptedRead(failure) + return retryableRead(failure) ? (zh ? "暂时无法读取原会话。已读记录会保留,连接恢复后将自动更新。" : "The conversation is temporarily unavailable. Existing records are retained; updates resume when the connection recovers.") : String(failure); } @@ -109,6 +113,15 @@ export function AttachedAgentConversations({goalId, zh, targetAgentId}: { const revision = useRef(""); const history = useRef<{source: string; size: number} | null>(null); const transcript = useRef(null); + const paging = useRef(null); + const pagingFailed = useRef(false); + const pagingRetry = useRef | undefined>(undefined); + const scrollDirection = useRef(0); + const scrollTop = useRef(0); + const touchY = useRef(null); + const draggingScroll = useRef(false); + const restoringScroll = useRef(false); + const position = useRef<{ids: string[]; group: boolean; offset: number; top: number} | null>(null); const readLocale = useRef(zh); useEffect(() => {readLocale.current = zh;}, [zh]); const binding = agents.find(agent => agent.agent_id === selected); @@ -122,6 +135,32 @@ export function AttachedAgentConversations({goalId, zh, targetAgentId}: { const liveActive = liveEnabled && !liveUnavailable && !readingHistory; const query = binding ? new URLSearchParams({goal_id: goalId, agent_id: binding.agent_id, binding_ref: binding.binding_ref}) : null; + useLayoutEffect(() => { + const element = transcript.current; + const saved = position.current; + position.current = null; + if (!element || !saved) return; + const ids = new Set(saved.ids); + const anchor = [...element.querySelectorAll("[data-record-anchor]")] + .find(node => (node.tagName === "SUMMARY") === saved.group && node.dataset.recordAnchor?.split(",").some(id => ids.has(id))); + restoringScroll.current = true; + element.scrollTop = anchor ? element.scrollTop + anchor.getBoundingClientRect().top - saved.offset : saved.top; + scrollTop.current = element.scrollTop; + requestAnimationFrame(() => {restoringScroll.current = false;}); + }, [messages]); + + // Collapsed tools and metadata can make a page shorter than the viewport. + // Fill it from history without requiring an otherwise impossible scroll. + useEffect(() => { + const frame = requestAnimationFrame(() => { + const element = transcript.current; + if (!element || error || loadingOlder) return; + if (element.scrollHeight <= element.clientHeight + 1 && messages.length < 300 && scrollDirection.current <= 0 && before !== null) void readHistory(false, true); + else if (scrollDirection.current) pageAtEdge(scrollDirection.current); + }); + return () => cancelAnimationFrame(frame); + }, [messages, liveView, before, loadingOlder, error]); + useEffect(() => { let active = true; latestLive.current = {identity: "", records: []}; setLiveView(latestLive.current); setLiveUnavailable(false); @@ -161,7 +200,7 @@ export function AttachedAgentConversations({goalId, zh, targetAgentId}: { } catch (failure) { if (active) { setError(readFailureMessage(failure, readLocale.current)); - if (interruptedRead(failure) || (failure instanceof ConversationRequestError && (failure.httpStatus ?? 0) >= 500)) timer = setTimeout(discover, 1000); + if (retryableRead(failure)) timer = setTimeout(discover, 1000); } } finally {if (active) setDiscovering(false);} } @@ -174,6 +213,7 @@ export function AttachedAgentConversations({goalId, zh, targetAgentId}: { revision.current = ""; history.current = null; after.current = null; following.current = true; historyEpoch.current++; + paging.current = null; pagingFailed.current = false; scrollDirection.current = 0; scrollTop.current = 0; position.current = null; setReadingHistory(false); setUnread(false); setOpening(false); setOpenRequested(false); setOpenError(""); setSendNotice(""); @@ -192,6 +232,7 @@ export function AttachedAgentConversations({goalId, zh, targetAgentId}: { } catch {setRecoveryError(zh ? "浏览器回复恢复记录不可读,暂不能安全发送。请先在原 App 核对。" : "Reply recovery is unreadable; check the original App before clearing it.");} let timer: ReturnType; async function read() { + if (paging.current !== null) {timer = setTimeout(read, 1000); return;} const epoch = historyEpoch.current; const params = new URLSearchParams(query!); if (following.current && after.current !== null) params.set("after", String(after.current)); @@ -215,7 +256,8 @@ export function AttachedAgentConversations({goalId, zh, targetAgentId}: { if (reset) {setBefore(next.before ?? null); resumeFollowing();} } else setUnread(true); } - setSnapshot(previous => ({...previous, ...next})); setError(""); + setSnapshot(previous => ({...previous, ...next})); + if (!pagingFailed.current) setError(""); if (follow && following.current) requestAnimationFrame(() => transcript.current?.scrollTo({top: transcript.current.scrollHeight})); } catch (failure) { if (current === generation.current) { @@ -226,7 +268,7 @@ export function AttachedAgentConversations({goalId, zh, targetAgentId}: { finally {if (current === generation.current) timer = setTimeout(read, 1000);} } void read(); - return () => {generation.current++; clearTimeout(timer);}; + return () => {generation.current++; clearTimeout(timer); clearTimeout(pagingRetry.current);}; }, [goalId, binding?.binding_ref]); async function openOriginal() { @@ -240,11 +282,14 @@ export function AttachedAgentConversations({goalId, zh, targetAgentId}: { finally {if (current === generation.current) setOpening(false);} } - async function older(newer = false) { - if ((!newer && before === null) || !query || loadingOlder) return; + async function readHistory(newer = false, fill = false) { + if ((!newer && before === null) || !query || paging.current !== null || pagingFailed.current) return; const current = generation.current; const epoch = ++historyEpoch.current; - following.current = false; setReadingHistory(true); + paging.current = epoch; + const keepFollowing = fill && following.current; + const tail = after.current; + if (!keepFollowing) {following.current = false; setReadingHistory(true);} setLoadingOlder(true); const params = new URLSearchParams(query); params.set(newer ? "after" : "before", String(newer ? after.current ?? 0 : before)); @@ -253,20 +298,38 @@ export function AttachedAgentConversations({goalId, zh, targetAgentId}: { const next = await readVisiblePage(params, () => current === generation.current && epoch === historyEpoch.current); if (current === generation.current && epoch === historyEpoch.current) { if (next.reset || !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."); - const previousHeight = transcript.current?.scrollHeight ?? 0; + // Anchor a visible source record, not total height: the opposite end of + // the 300-record window may be trimmed, or a tool group may grow. + const element = transcript.current; + if (element) { + const bounds = element.getBoundingClientRect(); + const anchor = [...element.querySelectorAll("[data-record-anchor]")].find(node => { + const rect = node.getBoundingClientRect(); + return rect.height > 0 && rect.bottom > bounds.top + 1 && rect.top < bounds.bottom; + }); + position.current = {ids: anchor?.dataset.recordAnchor?.split(",") ?? [], group: anchor?.tagName === "SUMMARY", offset: anchor?.getBoundingClientRect().top ?? bounds.top, top: element.scrollTop}; + } setMessages(previous => { const combined = [...new Map([...(next.messages ?? []), ...previous].map(row => [row.id, row])).values()].sort((a,b) => Number(a.id) - Number(b.id)); - const retained = newer ? combined.slice(-300) : combined.slice(0,300); - after.current = newer ? next.after ?? after.current : retained.at(-1)?.end ?? next.after ?? null; - setBefore(newer ? Number(retained[0]?.id) || null : next.before ?? null); + const retained = newer || keepFollowing ? combined.slice(-300) : combined.slice(0,300); + after.current = keepFollowing ? tail : newer ? next.after ?? after.current : retained.at(-1)?.end ?? next.after ?? null; + setBefore(newer || (keepFollowing && combined.length > 300) ? Number(retained[0]?.id) || null : next.before ?? null); return retained; }); - if (!newer) requestAnimationFrame(() => {if (transcript.current) transcript.current.scrollTop += transcript.current.scrollHeight - previousHeight;}); if (newer && !next.has_more) resumeFollowing(); setError(""); } - } catch (failure) {if (current === generation.current) setError(String(failure));} - finally {if (current === generation.current) setLoadingOlder(false);} + } catch (failure) { + if (current === generation.current) { + pagingFailed.current = true; setError(readFailureMessage(failure, zh)); + if (retryableRead(failure)) pagingRetry.current = setTimeout(() => { + if (current !== generation.current) return; + pagingFailed.current = false; + void readHistory(newer, fill); + }, 1000); + } + } + finally {if (paging.current === epoch) {paging.current = null; setLoadingOlder(false);}} } // Every path that returns to the live tail publishes the buffered projection. @@ -275,11 +338,26 @@ export function AttachedAgentConversations({goalId, zh, targetAgentId}: { 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 = ""; - setMessages([]); setBefore(null); - resumeFollowing(); + function pauseHistory() {following.current = false; scrollDirection.current = 0; setReadingHistory(true);} + function pageAtEdge(direction: number) { + const element = transcript.current; + if (!element || restoringScroll.current) return; + if (direction < 0 && element.scrollTop < 100) void readHistory(); + if (direction > 0 && !following.current && element.scrollHeight - element.scrollTop - element.clientHeight < 100) void readHistory(true); + } + function scrollIntent(direction: number) { + if (direction < 0) pauseHistory(); + scrollDirection.current = direction; + requestAnimationFrame(() => pageAtEdge(direction)); + } + function scrolled() { + const element = transcript.current; + if (!element) return; + const delta = element.scrollTop - scrollTop.current; + scrollTop.current = element.scrollTop; + if (restoringScroll.current) return; + if (draggingScroll.current && Math.abs(delta) > 1) scrollIntent(Math.sign(delta)); + else if (scrollDirection.current) pageAtEdge(scrollDirection.current); } async function loadContent(record: Message, offset: number): Promise { const params = new URLSearchParams(query!); @@ -346,26 +424,32 @@ export function AttachedAgentConversations({goalId, zh, targetAgentId}: { try {sessionStorage.removeItem(cacheKey); sessionStorage.removeItem(`${cacheKey}:draft`); receiptGeneration.current++; setSubmission(null); setDraft(""); setError(""); setRecoveryError("");} catch {setError(zh ? "无法清理回复回执。" : "Reply recovery could not be cleared.");} } + const connectionError = error ?

{error}

: null; return
{discovering ?

{zh ? "正在连接原会话…" : "Connecting to the original conversation…"}

: !binding && !error ?

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

: null} {recoveryError ?

{recoveryError}

: null} - {error ?

{error}

: null} + {!binding ? connectionError : null} {sendNotice ?

{sendNotice}

: null} {binding ? <>

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} - {before !== null ? : null} - {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 => )} +
{loadingOlder ? (zh ? "正在加载消息…" : "Loading messages…") : readingHistory ? {zh ? "正在回看历史" : "Reading history"}{unread ? (zh ? " · 有新活动" : " · new activity") : ""} : null}
+
{draggingScroll.current = event.target === event.currentTarget;}} onPointerUp={() => {draggingScroll.current = false;}} onPointerCancel={() => {draggingScroll.current = false;}} onWheel={event => {if (event.deltaY) scrollIntent(Math.sign(event.deltaY));}} onTouchStart={event => {touchY.current = event.touches.length === 1 ? event.touches[0].clientY : null;}} onTouchMove={event => { + if (touchY.current === null || event.touches.length !== 1) return; + const delta = touchY.current - event.touches[0].clientY; + touchY.current = event.touches[0].clientY; + if (delta) scrollIntent(Math.sign(delta)); + }} onClick={pauseHistory} onKeyDown={event => {if (["ArrowUp", "PageUp", "Home"].includes(event.key)) scrollIntent(-1); else if (["ArrowDown", "PageDown", "End"].includes(event.key)) scrollIntent(1);}} aria-label={zh ? "原会话消息" : "Original conversation messages"} aria-busy={loadingOlder}> + {liveRecords.length ?
- {liveRecords.map(record => )} +
: null} - {!messages.length && !liveRecords.length && snapshot ?

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

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

{zh ? "暂无可显示的消息。" : "No displayable messages yet."}

: null}
+ {connectionError} {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();}}>