diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index 93df623143..b7f760df8c 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -21,6 +21,7 @@ "smoke:benchmark-study": "rm -rf node_modules/.cache/loopx-benchmark-study-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck --strict --resolveJsonModule --outDir node_modules/.cache/loopx-benchmark-study-smoke smoke/benchmark-study-contract-smoke.ts src/data/benchmark-study.ts && node node_modules/.cache/loopx-benchmark-study-smoke/smoke/benchmark-study-contract-smoke.js", "smoke:benchmark-study-browser": "node ../../../examples/dashboard-benchmark-study-browser-smoke.mjs", "smoke:capability-configuration": "rm -rf node_modules/.cache/loopx-capability-configuration-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --types node --skipLibCheck --strict --outDir node_modules/.cache/loopx-capability-configuration-smoke smoke/capability-configuration-smoke.ts src/data/capability-configuration.ts && node node_modules/.cache/loopx-capability-configuration-smoke/smoke/capability-configuration-smoke.js", + "smoke:chat-model-menu": "vite build --ssr smoke/chat-model-menu-smoke.mjs --outDir node_modules/.cache/chat-model-menu --emptyOutDir && node node_modules/.cache/chat-model-menu/chat-model-menu-smoke.js", "smoke:chat-route": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --ignoreDeprecations 6.0 --skipLibCheck --strict --rootDir ../../.. --outDir node_modules/.cache/loopx-chat-route-smoke smoke/chat-route-smoke.ts src/data/chat-model.ts src/vite-env.d.ts && node node_modules/.cache/loopx-chat-route-smoke/apps/presentation/dashboard/smoke/chat-route-smoke.js", "smoke:chat-stream-stall": "vite build --ssr smoke/chat-stream-stall-smoke.ts --outDir node_modules/.cache/loopx-chat-stream-stall --emptyOutDir && node node_modules/.cache/loopx-chat-stream-stall/chat-stream-stall-smoke.js", "smoke:chat-turn-acceptance-retry": "vite build --ssr smoke/chat-turn-acceptance-retry-smoke.ts --outDir node_modules/.cache/loopx-chat-turn-acceptance-retry --emptyOutDir && node node_modules/.cache/loopx-chat-turn-acceptance-retry/chat-turn-acceptance-retry-smoke.js", @@ -38,10 +39,10 @@ "smoke:home-route": "rm -rf /tmp/loopx-home-route-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-home-route-smoke smoke/home-route-smoke.ts && node /tmp/loopx-home-route-smoke/home-route-smoke.js", "smoke:delegation-preflight": "rm -rf node_modules/.cache/loopx-delegation-preflight-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --jsx react-jsx --skipLibCheck --strict --rootDir . --outDir node_modules/.cache/loopx-delegation-preflight-smoke smoke/delegation-preflight-smoke.tsx src/data/delegation-preflight.ts src/features/personal-workspace/delegation-preflight-status.tsx && node node_modules/.cache/loopx-delegation-preflight-smoke/smoke/delegation-preflight-smoke.js", "smoke:delegation-preflight-browser": "node --experimental-strip-types smoke/delegation-preflight-browser-smoke.mjs", - "smoke:personal-workspace": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/runtime-readiness.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser-smoke.mjs", + "smoke:personal-workspace": "npm run smoke:chat-model-menu && npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs src/features/personal-workspace/composer-keyboard.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/runtime-readiness.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:workspace-locale": "LOOPX_PERSONAL_WORKSPACE_SCENARIO=workspace-locale node ../../../examples/personal-workspace-browser-smoke.mjs", - "smoke:personal-workspace-packaged": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/runtime-readiness.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-feedback-recovery-smoke.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-workspace-craft-browser-smoke.mjs", - "smoke:personal-workspace-packaged:catalog": "npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/runtime-readiness.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs", + "smoke:personal-workspace-packaged": "npm run smoke:chat-model-menu && npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs src/features/personal-workspace/composer-keyboard.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/runtime-readiness.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-feedback-recovery-smoke.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-workspace-craft-browser-smoke.mjs", + "smoke:personal-workspace-packaged:catalog": "npm run smoke:chat-model-menu && npm run smoke:conversation-history-order && npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && node src/features/personal-workspace/conversation-order.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/steering-recovery.test.mjs src/features/personal-workspace/composer-keyboard.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/runtime-readiness.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/attention-notifications.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/since-you-looked.test.mjs && node --no-warnings --experimental-strip-types --test src/features/personal-workspace/goal-suggestions.test.mjs && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser/server-startup.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:personal-workspace-packaged:team": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-evidence-return-smoke.mjs && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/team-feedback-recovery-smoke.mjs", "smoke:personal-workspace-packaged:craft": "LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-workspace-craft-browser-smoke.mjs", "smoke:todo-resume-condition": "rm -rf /tmp/loopx-todo-resume-condition-smoke && tsc --ignoreConfig --target ES2022 --module commonjs --moduleResolution node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-todo-resume-condition-smoke smoke/todo-resume-condition-smoke.ts src/features/personal-workspace/todo-resume-condition.ts && node /tmp/loopx-todo-resume-condition-smoke/smoke/todo-resume-condition-smoke.js", @@ -61,7 +62,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/smoke/attention-details-smoke.ts b/apps/presentation/dashboard/smoke/attention-details-smoke.ts index ff34cda9cd..070ecab774 100644 --- a/apps/presentation/dashboard/smoke/attention-details-smoke.ts +++ b/apps/presentation/dashboard/smoke/attention-details-smoke.ts @@ -13,13 +13,14 @@ function assert(condition: unknown, message: string): asserts condition { const source = { index: 1, todo_id: "todo_gate_one", done: false, status: "open", text: "Review change", task_class: "user_gate", note: "The chosen direction needs review", evidence: "review:bounded-check", - blocks_agent: "worker-one", unblocks_todo_id: "todo_target", + created_by: "request-author", blocks_agent: "worker-one", unblocks_todo_id: "todo_target", decision_scope: { schema_version: "decision_scope_v0", kind: "direction", granularity: "action", scope_key: "route-one" }, }; // Production Zod parse must preserve the public projection facts before mapping. const details = attentionDetails(todoItemSchema.parse(source)); assert(details.reason === source.note && details.evidence === source.evidence, "reason/evidence survived schema"); assert(details.blocksAgent === "worker-one" && details.unblocksTodoId === "todo_target", "exact relationships survive schema"); +assert(details.createdBy === "request-author" && details.boundAgent === null, "request provenance is not inferred from the recipient"); assert(details.decisionScope?.scopeKey === "route-one", "scope survives schema"); assert(details.interaction === "decision", "typed user gate is decision"); const row: WorkspaceAttention = { blocking: true, goalId: "goal-one", todoId: "todo_gate_one", sourceId: "source-a", text: source.text, details }; @@ -96,16 +97,21 @@ assert(longModel.userTodos[0]?.details?.evidence === source.evidence, "App decis const markup = renderToStaticMarkup(createElement(WorkspaceI18nProvider, null, createElement(AttentionDetailCard, { item: longModel.userTodos[0] }))); assert(markup.includes("Publish version 2.0 to stable only after acceptance."), "rendered App detail contains object beyond compact label"); assert(markup.includes(source.evidence), "rendered App detail contains evidence"); +assert(markup.includes("Agent request-author") && markup.includes("Agent worker-one"), "creator and reply recipient are visible without diagnostics"); +const unknownCreator = renderToStaticMarkup(createElement(WorkspaceI18nProvider, null, + createElement(AttentionDetailCard, { item: {...row, details: attentionDetails({...source, created_by: null})} }))); +assert(unknownCreator.includes("Requesting Agent not recorded") && !unknownCreator.includes("From Agent worker-one"), "legacy rows do not invent the creator from blocks_agent"); // Exercise the actual compact project_asset -> same QueueItem.user_todos join, // not a fixture that already supplies a complete body to attentionDetails. const canonical = todoItemSchema.parse({...source, role: "user", task_class: "user_action", updated_at: "2026-10-01T06:00:00Z", text: longRequest, note: "Only a learning review; expires at 2026-10-01T07:00:00Z."}); -const compact = todoItemSchema.parse({...canonical, text: `${longRequest.slice(0, 217)}...`, note: null, evidence: null}); +const compact = todoItemSchema.parse({...canonical, text: `${longRequest.slice(0, 217)}...`, note: null, evidence: null, created_by: null}); const joined = attentionDetailsFromSnapshot(compact, [canonical], row.goalId); assert(joined.requestText === longRequest && joined.reason === canonical.note && joined.evidence === canonical.evidence, "same revision restores full display content"); assert(joined.interaction === "user_action" && joined.lifecycle === "open", "enrichment cannot reclassify a user action as a gate"); +assert(joined.createdBy === "request-author", "same-snapshot content includes canonical author provenance"); const joinedMarkup = renderToStaticMarkup(createElement(WorkspaceI18nProvider, null, createElement(AttentionDetailCard, {item: {...row, text: compact.text, details: joined}}))); assert(joinedMarkup.includes("Publish version 2.0 to stable only after acceptance.") @@ -118,6 +124,7 @@ for (const candidates of [[], [canonical, canonical], [{...canonical, goal_id: " const rejected = attentionDetailsFromSnapshot(compact, candidates, row.goalId); assert(rejected.requestText === null && rejected.reason === null, "missing/conflicting details remain summary-only"); assert(rejected.lifecycle === "open" && rejected.interaction === "user_action", "failed enrichment preserves selected lifecycle and authority"); + assert(rejected.createdBy === null, "a failed join cannot import another snapshot's creator"); } const authoritySource = {...canonical, blocks_agent: "different-agent", decision_scope: {kind: "trade", granularity: "goal", scope_key: "different"}}; const displayOnly = attentionDetailsFromSnapshot(compact, [authoritySource], row.goalId); diff --git a/apps/presentation/dashboard/smoke/chat-model-menu-smoke.mjs b/apps/presentation/dashboard/smoke/chat-model-menu-smoke.mjs new file mode 100644 index 0000000000..c25404ebc6 --- /dev/null +++ b/apps/presentation/dashboard/smoke/chat-model-menu-smoke.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ChatModelMenu } from "../src/features/personal-workspace/chat-model-menu.tsx"; +import { WorkspaceI18nProvider } from "../src/features/personal-workspace/i18n.tsx"; + +// Presentation semantics only. Real width, focus, IME and native delivery still +// belong to the packaged browser and production HTTP/provider checks. +const originalWindow = globalThis.window; +let assertions = 0; +try { + for (const locale of ["zh-CN", "en"]) { + globalThis.window = { localStorage: { getItem: () => locale } }; + const render = props => renderToStaticMarkup(createElement(WorkspaceI18nProvider, null, + createElement(ChatModelMenu, { agentId: "codex", ordinary: true, model: "next-model", effort: "low", ...props }))); + const actual = render({ readback: { agent_model_id: "actual-model", agent_reasoning_effort: "high", agent_service_tier: "priority" } }); + const opening = actual.slice(0, actual.indexOf("")); + assert.doesNotMatch(opening, /\bopen(?:=|>|\s)/, "Optional settings are collapsed on entry"); + assert.match(summary, /actual-model/); + assert.match(summary, /high/); + assert.match(summary, /Fast/); + assert.doesNotMatch(summary, /next-model|low/, "Pending new settings never masquerade as the current Session"); + assert.match(actual, new RegExp(locale === "zh-CN" ? "新对话配置" : "New conversation settings")); + assert.match(actual, new RegExp(locale === "zh-CN" ? "保留原历史和活动回合" : "existing history and active work are preserved")); + const unknown = render({}); + const unknownSummary = unknown.slice(unknown.indexOf("")); + assert.match(unknownSummary, new RegExp(locale === "zh-CN" ? "模型未回传" : "Model not reported")); + assert.doesNotMatch(unknownSummary, /next-model|low|Fast/, "Neither defaults nor a pending choice fill an unknown observation"); + assert.doesNotMatch(render({ ordinary: false }), new RegExp(locale === "zh-CN" ? "新对话配置" : "New conversation settings"), "Steward settings keep their existing owner"); + assertions += 10; + } + console.log(`Chat model menu: ${assertions} bilingual current-vs-requested, unknown, collapsed and steward-boundary assertions passed (SSR only)`); +} finally { globalThis.window = originalWindow; } diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 62fd631c53..5a7fde15f5 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -721,6 +721,7 @@ export async function createChatSession( mode: "resume_latest" | "new" = "resume_latest", signal?: AbortSignal, agentModelId?: string, + agentReasoningEffort?: string, ) { return requestJson<{ agent_id: string; @@ -736,23 +737,24 @@ export async function createChatSession( // silently re-point the steward channel away from its configured executor. // A project Session carries only the host-issued reference, never a Goal. body: JSON.stringify(context.kind === "project" - ? { context_kind: "project", project_ref: context.projectRef, agent_id: agentId, agent_model_id: agentModelId, mode } - : { goal_id: context.kind === "goal" ? context.goalId : "", agent_id: agentId, agent_model_id: agentModelId, mode, context_kind: context.kind }), + ? { context_kind: "project", project_ref: context.projectRef, agent_id: agentId, agent_model_id: agentModelId, agent_reasoning_effort: agentReasoningEffort, mode } + : { goal_id: context.kind === "goal" ? context.goalId : "", agent_id: agentId, agent_model_id: agentModelId, agent_reasoning_effort: agentReasoningEffort, mode, context_kind: context.kind }), }); } -export type AgentModelOption = { value: string; label: string }; +export type AgentModelOption = { value: string; label: string; reasoning_efforts?: string[] }; export type AgentModelDiscovery = { ok: true; schema_version: "loopx_chat_agent_model_options_v1"; agent_id: string; supported: boolean; model: { id: string; label: string; selected: string; options: AgentModelOption[] } | null; + reasoning_effort?: { 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, modelId?: 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} : {}), ...(modelId ? {agent_model_id: modelId} : {})}), }); } @@ -770,6 +772,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; @@ -941,10 +945,12 @@ export async function acceptChatTurn( clientTurnId: string, attachments: ChatImageAttachmentInput[] = [], signal?: AbortSignal, + delivery?: "queue", ) { const body = JSON.stringify({ message, client_turn_id: clientTurnId, + ...(delivery ? { delivery } : {}), ...(attachments.length ? { attachments: attachments.map((attachment) => ({ data_url: attachment.dataUrl, id: attachment.id, diff --git a/apps/presentation/dashboard/src/data/conversation-returns.test.mjs b/apps/presentation/dashboard/src/data/conversation-returns.test.mjs index 99b92776b4..df0b32b49d 100644 --- a/apps/presentation/dashboard/src/data/conversation-returns.test.mjs +++ b/apps/presentation/dashboard/src/data/conversation-returns.test.mjs @@ -87,3 +87,14 @@ assert.equal(recoveredInstructions.find(row => row.text === "Chinese first").sou assert.equal(recoveredInstructions.find(row => row.text === "Do not publish").sourceMessageId, "instruction-2"); assert.equal(reconcileConversationHistory(recoveredInstructions, withInstructions, createHistory), recoveredInstructions); console.log("conversation-returns: passed (session isolation, late return, deduplication, transport uncertainty, stream preservation and continued revision observation)"); +// A fast history read may add the stored queue row before its HTTP receipt +// hydrates the original bubble. Both now carry the same canonical identity. +const queueRace = [ + {sourceSessionId: "current", sourceTurnId: "same-turn", role: "user", text: "queued"}, + {sourceSessionId: "current", sourceTurnId: "same-turn", sourceMessageId: "user-message", role: "user", text: "queued"}, +]; +const queueReadback = reconcileConversationHistory(queueRace, [storedRoles[0]], createHistory); +assert.equal(queueReadback.length, 1, "Queue admission/history races retain exactly one canonical user message"); +assert.equal(queueReadback[0].sourceMessageId, "user-message"); +const crossSession = [...queueReadback, {...queueReadback[0], sourceSessionId: "different"}]; +assert.equal(reconcileConversationHistory(crossSession, [storedRoles[0]], createHistory).length, 2, "Message ids are scoped to their original Session"); diff --git a/apps/presentation/dashboard/src/data/conversation-returns.ts b/apps/presentation/dashboard/src/data/conversation-returns.ts index 6fa84a15de..0d8689f84e 100644 --- a/apps/presentation/dashboard/src/data/conversation-returns.ts +++ b/apps/presentation/dashboard/src/data/conversation-returns.ts @@ -83,6 +83,17 @@ export function reconcileConversationHistory( for (const sessionId of sessions) { updated = reconcileConversationReturns(updated, sessionId, messages.filter((message) => message.session_id === sessionId), createMessage); } + // A history read can arrive before admission hydrates the optimistic row. + // Deduplicate only established Session/message identities, never body text. + const identities = new Set(); + const unique = updated.filter((message) => { + if (!message.sourceSessionId || !message.sourceMessageId) return true; + const key = JSON.stringify([message.sourceSessionId, message.sourceMessageId]); + if (identities.has(key)) return false; + identities.add(key); + return true; + }); + if (unique.length !== updated.length) updated = unique; const seen = new Set(updated.map((message) => `${message.sourceSessionId}:${message.sourceMessageId}`)); const recovered = messages.filter((message) => !seen.has(`${message.session_id}:${message.message_id}`)).map(createMessage); if (!recovered.length) return updated; 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/data/use-conversation-history.ts b/apps/presentation/dashboard/src/data/use-conversation-history.ts index bf3dd7f25d..95e43a2349 100644 --- a/apps/presentation/dashboard/src/data/use-conversation-history.ts +++ b/apps/presentation/dashboard/src/data/use-conversation-history.ts @@ -26,7 +26,6 @@ export function useConversationHistory({ agentId, currentAgentId, channelId, goa const scope = JSON.stringify([agentId ?? null, currentAgentId, channelId, goalId ?? null]); const [state, setState] = useState(null); const retryRead = useRef<(() => void) | null>(null); - const retry = useCallback(() => retryRead.current?.(), []); const cached = useRef<{ scope: string; history: ChatHistory } | null>(null); const refreshGeneration = useRef(0); const cachedHistory = (forScope: string) => cached.current?.scope === forScope ? cached.current.history : null; @@ -39,7 +38,15 @@ export function useConversationHistory({ agentId, currentAgentId, channelId, goa const refresh = useCallback(async () => { const { agentId, channelId, goalId, scope } = params.current; const generation = ++refreshGeneration.current; - const loaded = await fetchChatHistory({ agentId, channelId, goalId }); + let loaded: ChatHistory; + try { + loaded = await fetchChatHistory({ agentId, channelId, goalId }); + } catch (error) { + if (params.current.scope === scope && generation === refreshGeneration.current) { + setState({ scope, history: cachedHistory(scope), reading: false, failed: true }); + } + throw error; + } // The caller may still need its completed read, but a conversation that // was left while it waited no longer owns this hook's visible history. if (params.current.scope !== scope || generation !== refreshGeneration.current) return loaded; @@ -47,6 +54,14 @@ export function useConversationHistory({ agentId, currentAgentId, channelId, goa setState({ scope, history: loaded, reading: false, failed: false }); return loaded; }, []); + const retry = useCallback(() => { + if (state?.scope === params.current.scope && state.failed) { + // A failed refresh may hide new records in an already cached Session. + // Re-read that snapshot rather than retrying only missing older history. + setState({ ...state, reading: true }); + void refresh().catch(() => {}); + } else retryRead.current?.(); + }, [state, refresh]); useEffect(() => { if (!enabled) return; let cancelled = false; 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();}}>