From 8a121dc263e1d2c40752cf9246b8a0fc740624f9 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:37:25 +0800 Subject: [PATCH 1/3] feat(chat): open saved long answers in a focused reader Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- apps/presentation/dashboard/src/data/chat.ts | 4 +- .../personal-workspace/answer-report-page.css | 29 +++++ .../personal-workspace/answer-report-page.tsx | 100 ++++++++++++++++++ .../personal-workspace/answer-text.ts | 15 +++ .../personal-workspace/channel-timeline.tsx | 14 +++ .../personal-workspace-model.ts | 2 + apps/presentation/dashboard/src/router.tsx | 10 +- .../dashboard/src/views/dashboard-page.tsx | 38 ++++--- 8 files changed, 196 insertions(+), 16 deletions(-) create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/answer-report-page.css create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/answer-report-page.tsx create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/answer-text.ts diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index ff2a176686..aac427d35b 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -675,6 +675,8 @@ export type ManagerRuntimeSessionReadback = { }; export type ChatVisibleMessage = { + /** Client-side lineage added when messages from several Sessions are merged. */ + session_id?: string; collaboration?: CollaborationReadback; origin?: string; attachments?: ChatImageAttachment[]; @@ -742,7 +744,7 @@ export function mergeChatSessionMessages(snapshots: ChatSessionSnapshot[]) { const messages = new Map(); for (const snapshot of snapshots) { for (const message of snapshot.messages) { - messages.set(message.message_id, message); + messages.set(message.message_id, { ...message, session_id: snapshot.session.session_id }); } } return [...messages.values()].sort((left, right) => diff --git a/apps/presentation/dashboard/src/features/personal-workspace/answer-report-page.css b/apps/presentation/dashboard/src/features/personal-workspace/answer-report-page.css new file mode 100644 index 0000000000..17a56f42b2 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/answer-report-page.css @@ -0,0 +1,29 @@ +.answer-report-page { min-height: 100dvh; background: #fafafa; color: #171717; padding: 0 20px 64px; } +.answer-report-shell { max-width: 920px; margin: 0 auto; } +.answer-report-header { display: flex; justify-content: space-between; align-items: center; gap: 20px; min-height: 84px; border-bottom: 1px solid #e7e7e7; } +.answer-report-header > div { display: flex; align-items: baseline; flex-wrap: wrap; gap: 12px; } +.answer-report-brand { font-size: 17px; font-weight: 700; letter-spacing: -.04em; } +.answer-report-context { color: #777; font-size: 13px; overflow-wrap: anywhere; } +.answer-report-header button, .answer-report-heading button { min-height: 44px; border: 1px solid #d7d7d7; border-radius: 8px; background: #fff; color: #171717; padding: 8px 14px; font: inherit; font-size: 13px; cursor: pointer; } +.answer-report-header button:hover, .answer-report-heading button:hover { border-color: #8f8f8f; } +.answer-report-page button:focus-visible, .answer-report-page summary:focus-visible { outline: 2px solid #0070f3; outline-offset: 3px; } +.answer-report-body { padding: 48px min(5vw, 56px) 56px; margin: 28px 0 0; background: #fff; border: 1px solid #ebebeb; border-radius: 14px; min-width: 0; } +.answer-report-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; border-bottom: 1px solid #ebebeb; padding-bottom: 24px; margin-bottom: 28px; } +.answer-report-heading small { color: #666; font-size: 12px; } +.answer-report-heading h1 { font-size: clamp(24px, 3vw, 32px); letter-spacing: -.035em; line-height: 1.25; margin: 6px 0 8px; } +.answer-report-heading p { color: #777; margin: 0; font-size: 12px; } +.answer-report-content { min-width: 0; overflow-wrap: anywhere; } +.answer-report-content .personal-md { display: grid; gap: 18px; font-size: 15px; line-height: 1.8; } +.answer-report-content .personal-md p { margin: 0; font-size: inherit; line-height: inherit; } +.answer-report-content .personal-md-heading.is-h1 { font-size: 24px; font-weight: 650; } +.answer-report-content .personal-md-heading.is-h2 { font-size: 19px; font-weight: 600; } +.answer-report-content .personal-md-table-scroll { max-width: 100%; overflow-x: auto; } +.answer-report-body footer { border-top: 1px solid #ebebeb; margin-top: 40px; padding-top: 16px; color: #666; font-size: 12px; line-height: 1.6; } +.answer-report-body footer summary { cursor: pointer; min-height: 36px; align-content: center; } +.answer-report-body footer dl { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 8px; } +.answer-report-body footer dd { margin: 0; overflow-wrap: anywhere; } +.answer-report-body footer code { font-size: 11px; } +.personal-answer-link { display: inline-block; margin-top: 8px; font-size: 12px; color: var(--pw-blue-ink, #0070f3); text-decoration: none; } +.personal-answer-link:hover { text-decoration: underline; } +.personal-answer-link:focus-visible { outline: 2px solid #0070f3; outline-offset: 3px; } +@media (max-width: 640px) { .answer-report-page { padding: 0 12px 28px; } .answer-report-header { min-height: 68px; } .answer-report-body { margin-top: 12px; padding: 28px 16px 36px; } } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/answer-report-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/answer-report-page.tsx new file mode 100644 index 0000000000..78d6138019 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/answer-report-page.tsx @@ -0,0 +1,100 @@ +import {useEffect, useState} from "react"; + +import {fetchChatSession, type ChatSessionSnapshot, type ChatVisibleMessage} from "../../data/chat"; +import {visibleAgentMessage} from "./answer-text"; +import {readWorkspaceLocale} from "./i18n"; +import {MarkdownText} from "./markdown"; +import "./personal-workspace.css"; +import "./goal-loopx-mode.css"; +import "./answer-report-page.css"; + +type ReportState = + | {kind: "loading"} + | {kind: "ready"; snapshot: ChatSessionSnapshot; message: ChatVisibleMessage} + | {kind: "missing"} + | {kind: "error"}; + +export function AnswerReportPage({sessionId, messageId, statusUrl}: { + sessionId: string; + messageId: string; + statusUrl: string; +}) { + const [state, setState] = useState({kind: "loading"}); + const [copied, setCopied] = useState(false); + const [copyFailed, setCopyFailed] = useState(false); + const zh = readWorkspaceLocale() === "zh-CN"; + + useEffect(() => { + let live = true; + const previousTitle = document.title; + document.title = zh ? "答复记录 · LoopX" : "Answer record · LoopX"; + setState({kind: "loading"}); + void fetchChatSession(sessionId).then((snapshot) => { + if (!live) return; + const message = snapshot.messages.find((item) => item.message_id === messageId + && ["agent", "assistant"].includes(item.role)); + setState(message ? {kind: "ready", snapshot, message} : {kind: "missing"}); + }).catch(() => { if (live) setState({kind: "error"}); }); + return () => { live = false; document.title = previousTitle; }; + }, [sessionId, messageId, zh]); + + const currentUrl = new URL(window.location.href); + currentUrl.searchParams.delete("reportSessionId"); + currentUrl.searchParams.delete("reportMessageId"); + currentUrl.searchParams.set("goalId", state.kind === "ready" && state.snapshot.session.goal_id !== "loopx-manager" + ? state.snapshot.session.goal_id : ""); + if (statusUrl) currentUrl.searchParams.set("statusUrl", statusUrl); + const workspaceUrl = currentUrl.toString(); + const closeOrReturn = () => { + if (window.opener) window.close(); + else window.location.assign(workspaceUrl); + }; + + const answer = state.kind === "ready" ? state.message : null; + const answerText = answer ? visibleAgentMessage(answer.text) : ""; + const sourceQuestion = state.kind === "ready" + ? state.snapshot.messages.find((item) => item.turn_id === answer?.turn_id && item.role === "user") + : null; + const created = answer?.created_at ? new Date(answer.created_at) : null; + const dateLabel = created && !Number.isNaN(created.valueOf()) + ? new Intl.DateTimeFormat(zh ? "zh-CN" : "en", {dateStyle: "medium", timeStyle: "short"}).format(created) + : null; + + return
+
+
+
LoopX + {state.kind === "ready" && state.snapshot.session.goal_id !== "loopx-manager" + ? state.snapshot.session.goal_id : zh ? "管家" : "Steward"} +
+ +
+
+
{zh ? "已保存的答复" : "Saved answer"} +

{zh ? "完整答复" : "Full answer"}

+ {dateLabel ?

: null} +
{answer ? : null}
+ {copyFailed ?

{zh ? "复制失败。请从正文中选中并复制。" : "Copy failed. Select and copy the answer text instead."}

: null} + {state.kind === "loading" ?

{zh ? "正在读取已保存的答复…" : "Loading the saved answer…"}

: null} + {state.kind === "error" ?

{zh ? "暂时无法读取这份答复;请检查本机 LoopX 服务。" : "Could not load this answer. Check the local LoopX service."}

: null} + {state.kind === "missing" ?

{zh ? "找不到这份答复。它可能已被清理,或链接有误。" : "This answer was not found. It may have been removed, or the link may be incorrect."}

: null} + {answer ? <> +
+

{zh ? "来自本机保存的对话消息;打开此页不会重新运行 Agent。" : "From a locally saved conversation message. Opening this page does not rerun the Agent."}

+
{zh ? "来源与版本" : "Source and version"} +
{zh ? "消息" : "Message"}
{answer.message_id}
+
Session
{sessionId}
+ {sourceQuestion?.text ?

{zh ? "原问题:" : "Original question: "}{sourceQuestion.text}

: null} +
+
+ : null} +
+
+
; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/answer-text.ts b/apps/presentation/dashboard/src/features/personal-workspace/answer-text.ts new file mode 100644 index 0000000000..68055c6e54 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/answer-text.ts @@ -0,0 +1,15 @@ +/** Keep the saved-answer reader and the original conversation's visible text aligned. */ +export const MIN_SEPARATE_ANSWER_LENGTH = 160; + +export function visibleAgentMessage(value: string) { + return value + .split(/\r?\n/u) + .filter((line) => !/^\s*GOAL_(STATUS|PROGRESS)\s*:/u.test(line)) + .map((line) => { + if (/^\s*GOAL_EVIDENCE\s*:/u.test(line)) return line.replace(/^\s*GOAL_EVIDENCE\s*:/u, "验证依据:"); + if (/^\s*NEXT_ACTION\s*:/u.test(line)) return line.replace(/^\s*NEXT_ACTION\s*:/u, "下一步:"); + return line; + }) + .join("\n") + .trim(); +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx index 846c9586bd..5d5199a703 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx @@ -4,6 +4,7 @@ import { CollaborationCard } from "./collaboration-card"; import { Activity, Bot, Sparkles, Square } from "lucide-react"; import { AttentionRow } from "./cards/attention-row"; +import { MIN_SEPARATE_ANSWER_LENGTH } from "./answer-text"; import { MarkdownText } from "./markdown"; import { OutputRow } from "./cards/output-row"; import { RunRow } from "./cards/run-row"; @@ -13,6 +14,14 @@ import { ReturnDeliveryStatus } from "./return-delivery-status"; import {ManagerTeamResult} from "./manager-team-result"; import type { WorkspaceDrawerSelection, WorkspaceGoal, WorkspaceMessage, WorkspaceTimelineItem } from "./personal-workspace-model"; +function answerLink(sessionId: string, messageId: string) { + const url = new URL(window.location.href); + url.searchParams.set("reportSessionId", sessionId); + url.searchParams.set("reportMessageId", messageId); + url.hash = ""; + return url.toString(); +} + function MessageActivity({ message, onInterruptTurn, onSteerTurn }: { message: WorkspaceMessage; onInterruptTurn?: (turnId: string) => Promise; @@ -180,6 +189,11 @@ export function ChannelTimeline({
{item.message.role === "user" ? t("common.you") : item.message.agentLabel ?? t("header.manager")}{item.message.time ? : null}
{item.message.attachments?.length ?
{item.message.attachments.map((attachment) => {attachment.name})}
: null} {item.message.role === "user" ?

{item.message.text}

: item.message.text ? : null} + {item.message.role === "assistant" && !item.message.pending && item.message.text.length >= MIN_SEPARATE_ANSWER_LENGTH + && item.message.sourceSessionId && item.message.sourceMessageId + ? {locale === "zh-CN" ? "单独阅读完整答复" : "Read full answer separately"} + : null} {item.message.role !== "user" && (item.message.pending || item.message.sourceTurnId || item.message.activity?.length) ? : null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index abf9ca0672..1f006cfbcc 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -269,6 +269,8 @@ export type WorkspaceMessage = { returnDelivery?: WorkspaceReturnDelivery; role: "assistant" | "user" | "system"; sourceTurnId?: string; + sourceMessageId?: string; + sourceSessionId?: string; text: string; time?: string; }; diff --git a/apps/presentation/dashboard/src/router.tsx b/apps/presentation/dashboard/src/router.tsx index 57cb2e6387..205deb4c51 100644 --- a/apps/presentation/dashboard/src/router.tsx +++ b/apps/presentation/dashboard/src/router.tsx @@ -12,10 +12,13 @@ import { FrontstageDeveloperPage } from "./views/frontstage-developer-page"; import { useEffect } from "react"; import { resolveLocalStatusUrl } from "./data/local-status-query"; import { BenchmarkStudyPage } from "./views/benchmark-study-page"; +import { AnswerReportPage } from "./features/personal-workspace/answer-report-page"; const searchSchema = z.object({ goalId: z.string().optional().default(""), statusUrl: z.string().optional().default(""), + reportSessionId: z.string().regex(/^[A-Za-z0-9._-]{1,160}$/).optional(), + reportMessageId: z.string().regex(/^[A-Za-z0-9._-]{1,160}$/).optional(), }); const frontstageSearchSchema = z.object({ @@ -71,7 +74,12 @@ export const dashboardRoute = createRoute({ getParentRoute: () => rootRoute, path: "/", validateSearch: (search) => searchSchema.parse(search), - component: DashboardPage, + component: () => { + const search = dashboardRoute.useSearch(); + return search.reportSessionId && search.reportMessageId + ? + : ; + }, }); export const frontstageRoute = createRoute({ diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index e442c2e25c..0ab6370516 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -85,6 +85,7 @@ import { presentedAgentFamily, } from "../features/personal-workspace/agent-family"; import { PersonalWorkspacePage } from "../features/personal-workspace/personal-workspace-page"; +import { MIN_SEPARATE_ANSWER_LENGTH, visibleAgentMessage } from "../features/personal-workspace/answer-text"; import { useWorkspaceI18n, type WorkspaceTranslate } from "../features/personal-workspace/i18n"; import { agentStatusSentence, @@ -497,6 +498,7 @@ type PersonalHomeModel = { }; type PersonalManagerMessage = { sourceMessageId?: string; + sourceSessionId?: string; sourceTurnId?: string; activity?: string[]; agentLabel?: string; @@ -616,19 +618,6 @@ function personalGoalTitle(goalId: string, displayName?: string | null) { .join(" "); } -function visibleAgentMessage(value: string) { - return value - .split(/\r?\n/u) - .filter((line) => !/^\s*GOAL_(STATUS|PROGRESS)\s*:/u.test(line)) - .map((line) => { - if (/^\s*GOAL_EVIDENCE\s*:/u.test(line)) return line.replace(/^\s*GOAL_EVIDENCE\s*:/u, "验证依据:"); - if (/^\s*NEXT_ACTION\s*:/u.test(line)) return line.replace(/^\s*NEXT_ACTION\s*:/u, "下一步:"); - return line; - }) - .join("\n") - .trim(); -} - function isAgentResultMessage(role: string, text: string) { return ["agent", "assistant"].includes(role.trim().toLowerCase()) && text.trim().length > 0; } @@ -1586,11 +1575,14 @@ function PersonalGoalHome({ const collaboration = row.sourceMessageId ? collaborationByMessage.get(row.sourceMessageId) : source?.collaboration; if (JSON.stringify(delivery) === JSON.stringify(row.returnDelivery) && JSON.stringify(collaboration) === JSON.stringify(row.collaboration)) return row; deliveryChanged = true; - return { ...row, sourceMessageId: row.sourceMessageId ?? source?.message_id, returnDelivery: delivery, collaboration }; + return { ...row, sourceMessageId: row.sourceMessageId ?? source?.message_id, + sourceSessionId: row.sourceSessionId ?? (source?.message_id ? conversationReturnSessionId : undefined), + returnDelivery: delivery, collaboration }; }); if (!fresh.length && !deliveryChanged) return current; return { ...current, [contextId]: [...updated, ...fresh.map((row) => ({ id: managerMessageId.current++, sourceMessageId: row.message_id, + sourceSessionId: conversationReturnSessionId, role: "assistant" as const, agentLabel: "协作回执", sourceLabel: "协作回执", text: visibleAgentMessage(row.text), lines: [], @@ -1688,6 +1680,7 @@ function PersonalGoalHome({ ...current, [targetContextId]: history.messages.map((message) => ({ sourceMessageId: message.message_id, + sourceSessionId: message.session_id, agentLabel: message.role === "user" ? undefined : message.origin === "manager_followup" @@ -2219,6 +2212,19 @@ function PersonalGoalHome({ text: visibleAgentMessage(response.message || streamedText.trim()) || `${answerIdentityLabel(targetContextId, selectedRoute.label)} 已完成分析。`, }); + // The completed transcript is the immutable answer owner. Resolve its + // stored identity before offering a link; a failed read never hides the + // visible answer or retries the model Turn. + const completedMessageId = streamingMessageId; + if (completedMessageId !== null && (response.message || streamedText).length >= MIN_SEPARATE_ANSWER_LENGTH) { + void fetchChatSession(sessionId).then((stored) => { + const answer = stored.messages.find((item) => + item.turn_id === streamed.turnId && ["agent", "assistant"].includes(item.role)); + if (answer) updateManagerAssistantMessage(targetContextId, completedMessageId, { + sourceMessageId: answer.message_id, sourceSessionId: sessionId, + }); + }).catch(() => { /* The original conversation remains readable. */ }); + } const todoProposals = response.proposals.filter(isTodoProposal); // The channel already states where a team plan is confirmed: its answer // names the Goal whose workspace holds the card, so a manager-channel @@ -2620,6 +2626,8 @@ function PersonalGoalHome({ collaboration: message.collaboration, role: message.role, sourceTurnId: message.sourceTurnId, + sourceMessageId: message.sourceMessageId, + sourceSessionId: message.sourceSessionId, text: message.text || (message.pending ? "" : message.lines.join("\n")), }, })), @@ -2805,6 +2813,8 @@ function PersonalGoalHome({ setMessagesByContext((current) => ({ ...current, [run.goalId]: snapshot.messages.map((message) => ({ + sourceMessageId: message.message_id, + sourceSessionId: sessionId, agentLabel: message.role === "user" ? undefined : run.agentLabel, attachments: workspaceImageAttachments(message.attachments), id: managerMessageId.current++, From b50728abfc226c21106510f829c4f5d944bb5063 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:37:37 +0800 Subject: [PATCH 2/3] test(chat): verify saved-answer reading in packaged UI Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- ...lligent-review-presentation-surfaces-v0.md | 6 +++ .../answer-presentation.mjs | 37 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/docs/architecture/rfcs/intelligent-review-presentation-surfaces-v0.md b/docs/architecture/rfcs/intelligent-review-presentation-surfaces-v0.md index 66d994da32..5f40df64fe 100644 --- a/docs/architecture/rfcs/intelligent-review-presentation-surfaces-v0.md +++ b/docs/architecture/rfcs/intelligent-review-presentation-surfaces-v0.md @@ -706,6 +706,12 @@ records Markdown format and answer length rather than claiming answer quality. This does not yet establish qualitative live-model answer quality, a separately addressable versioned report for every investigation, or complete long-answer delivery across every external channel; those remain Stage 3 acceptance work. +The next local Chat slice gives substantive, completed answers a stable reader +link backed by the immutable saved Session message id. The reader reuses the +safe Markdown renderer, preserves the original conversation in its own tab, +and never starts another model Turn on open or reload. This is a local answer +record, not a claim that every investigation has a separately governed report +artifact; cross-channel delivery and report artifact ownership remain open. Project only real host events into a compact current-phase line and expandable history: accepted, queued, started, tool/public progress, waiting, interrupted, diff --git a/examples/personal-workspace-browser/answer-presentation.mjs b/examples/personal-workspace-browser/answer-presentation.mjs index 0a1ad4182f..c1d453e2e6 100644 --- a/examples/personal-workspace-browser/answer-presentation.mjs +++ b/examples/personal-workspace-browser/answer-presentation.mjs @@ -53,6 +53,7 @@ export const answerPresentationScenario = { if (await answer.locator("script").count() || !((await answer.innerText()).includes("下一步:请项目 Agent"))) { throw new Error("Steward answer lost its ending or executed model HTML"); } + await answer.getByRole("link", { name: "单独阅读完整答复" }).waitFor({ state: "visible", timeout: 15_000 }); await answer.scrollIntoViewIfNeeded(); await page.screenshot({ path: resolve(outputDir, "answer-presentation-desktop.png"), fullPage: false, animations: "disabled" }); @@ -79,6 +80,42 @@ export const answerPresentationScenario = { if (await restored.locator("table").count() !== 1 || !(await restored.innerText()).includes("可回退路径")) { throw new Error("Reload lost the complete Markdown answer in the original conversation"); } + const reportLink = restored.getByRole("link", { name: "单独阅读完整答复" }); + await reportLink.waitFor({ state: "visible", timeout: 15_000 }); + if (await reportLink.getAttribute("target") !== "_blank") { + throw new Error("The answer link must leave the original conversation open"); + } + const reportHref = await reportLink.getAttribute("href"); + if (!reportHref) throw new Error("Saved answer has no stable link"); + const originalTurnCount = api.turnRequests.length; + await page.goto(reportHref, { waitUntil: "networkidle" }); + await page.getByRole("heading", { name: "完整答复" }).waitFor({ state: "visible" }); + if (await page.locator(".answer-report-content table").count() !== 1 + || !(await page.locator(".answer-report-content").innerText()).includes("可回退路径") + || await page.locator(".answer-report-content script").count() || await page.evaluate(() => window.pwned === true)) { + throw new Error("The standalone answer lost content or executed model HTML"); + } + await page.screenshot({ path: resolve(outputDir, "answer-report-desktop.png"), fullPage: false, animations: "disabled" }); + await page.setViewportSize({ width: 390, height: 844 }); + const reportOverflow = await page.locator(".answer-report-body").evaluate((node) => node.scrollWidth > node.clientWidth + 1); + if (reportOverflow) throw new Error("The standalone answer overflows its mobile reading surface"); + await page.screenshot({ path: resolve(outputDir, "answer-report-mobile.png"), fullPage: false, animations: "disabled" }); + await page.setViewportSize({ width: 1512, height: 982 }); + await page.reload({ waitUntil: "networkidle" }); + if (await page.locator(".answer-report-content table").count() !== 1 + || api.turnRequests.length !== originalTurnCount) { + throw new Error("Reloading the answer link replayed a Turn or lost its content"); + } + await page.evaluate(() => localStorage.setItem("loopx-pw-locale", "en")); + await page.reload({ waitUntil: "networkidle" }); + await page.getByRole("heading", { name: "Full answer" }).waitFor({ state: "visible" }); + await page.evaluate(() => localStorage.setItem("loopx-pw-locale", "zh-CN")); + const missingUrl = new URL(reportHref); + missingUrl.searchParams.set("reportMessageId", "missing-answer"); + await page.goto(missingUrl.toString(), { waitUntil: "networkidle" }); + await page.locator('[role="alert"]', { hasText: "找不到这份答复" }).waitFor({ state: "visible" }); + if (api.turnRequests.length !== originalTurnCount) throw new Error("Missing report replayed a Turn"); + await page.goto(url, { waitUntil: "networkidle" }); await page.getByRole("navigation", { name: "管家视图" }) .getByRole("button", { name: "总览", exact: true }).click(); From cbe34b942298853318ba482ac8d50ca037a7a8cd Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:31:49 +0800 Subject: [PATCH 3/3] fix(chat): return saved steward answers to the conversation Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../src/features/personal-workspace/channel-timeline.tsx | 1 + .../personal-workspace/personal-workspace-page.tsx | 4 +++- apps/presentation/dashboard/src/router.tsx | 1 + apps/presentation/dashboard/src/views/dashboard-page.tsx | 4 ++++ .../personal-workspace-browser/answer-presentation.mjs | 7 +++++++ 5 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx index 5d5199a703..5325c10eb1 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx @@ -18,6 +18,7 @@ function answerLink(sessionId: string, messageId: string) { const url = new URL(window.location.href); url.searchParams.set("reportSessionId", sessionId); url.searchParams.set("reportMessageId", messageId); + url.searchParams.set("view", "conversation"); url.hash = ""; return url.toString(); } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 64dd51c4f4..f4eb0cd147 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -828,6 +828,7 @@ export function PersonalWorkspacePage({ agents = [{ agentId: "codex", available: true, capability: "代码与项目执行", label: "Codex" }], callbacks = {}, goalArchiveLoadState = { error: null, phase: "ready" }, + initialManagerChatOpen = false, managerChannelBinding, managerRuntime, model, @@ -840,6 +841,7 @@ export function PersonalWorkspacePage({ agents?: WorkspaceAgentOption[]; callbacks?: PersonalWorkspaceCallbacks; goalArchiveLoadState?: WorkspaceGoalArchiveLoadState; + initialManagerChatOpen?: boolean; managerChannelBinding?: ManagerChannelBinding | null; managerRuntime?: ManagerRuntimeSessionReadback | null; model: WorkspaceModel; @@ -857,7 +859,7 @@ export function PersonalWorkspacePage({ const [activeSessionRun, setActiveSessionRun] = useState(null); const [proposals, setProposals] = useState>({}); const [selectedGoalTab, setSelectedGoalTab] = useState("chat"); - const [managerChatOpen, setManagerChatOpen] = useState(false); + const [managerChatOpen, setManagerChatOpen] = useState(initialManagerChatOpen); const [managerConversationReceiptVisible, setManagerConversationReceiptVisible] = useState(false); const [goalConversationReceiptVisible, setGoalConversationReceiptVisible] = useState(false); const [drafts, setDrafts] = useState>(() => { diff --git a/apps/presentation/dashboard/src/router.tsx b/apps/presentation/dashboard/src/router.tsx index 205deb4c51..35b57ca6ce 100644 --- a/apps/presentation/dashboard/src/router.tsx +++ b/apps/presentation/dashboard/src/router.tsx @@ -17,6 +17,7 @@ import { AnswerReportPage } from "./features/personal-workspace/answer-report-pa const searchSchema = z.object({ goalId: z.string().optional().default(""), statusUrl: z.string().optional().default(""), + view: z.literal("conversation").optional(), reportSessionId: z.string().regex(/^[A-Za-z0-9._-]{1,160}$/).optional(), reportMessageId: z.string().regex(/^[A-Za-z0-9._-]{1,160}$/).optional(), }); diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 0ab6370516..79876e845f 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -1298,6 +1298,7 @@ function buildPersonalHomeModel( } function PersonalGoalHome({ goalArchiveLoadState, + initialManagerChatOpen, isLoading, onGoalActivationStateChange, onGoalDeleted, @@ -1314,6 +1315,7 @@ function PersonalGoalHome({ toggleTheme, }: { goalArchiveLoadState: WorkspaceGoalArchiveLoadState; + initialManagerChatOpen: boolean; isLoading: boolean; onGoalActivationStateChange: (goalId: string, activationState: "active" | "stopped") => void; onGoalDeleted: (goalId: string) => void; @@ -2922,6 +2924,7 @@ function PersonalGoalHome({ onStartNewRunSession: startNewManagerSession, }} goalArchiveLoadState={goalArchiveLoadState} + initialManagerChatOpen={initialManagerChatOpen} managerChannelBinding={managerChannelBinding} managerRuntime={managerRuntime} conversationSessionId={runtimeBindings[contextId]?.sessionId} @@ -3396,6 +3399,7 @@ export function DashboardPage() { return ( { statusRequestFenceRef.current.projectionRevision += 1; diff --git a/examples/personal-workspace-browser/answer-presentation.mjs b/examples/personal-workspace-browser/answer-presentation.mjs index c1d453e2e6..9a83172746 100644 --- a/examples/personal-workspace-browser/answer-presentation.mjs +++ b/examples/personal-workspace-browser/answer-presentation.mjs @@ -110,6 +110,13 @@ export const answerPresentationScenario = { await page.reload({ waitUntil: "networkidle" }); await page.getByRole("heading", { name: "Full answer" }).waitFor({ state: "visible" }); await page.evaluate(() => localStorage.setItem("loopx-pw-locale", "zh-CN")); + await page.getByRole("button", { name: "Back to conversation" }).click(); + await page.locator(".personal-channel-timeline .personal-message.is-assistant", { + hasText: "建议先验证方案 A", + }).waitFor({ state: "visible", timeout: 15_000 }); + if (api.turnRequests.length !== originalTurnCount) { + throw new Error("Returning to the Steward conversation replayed a Turn"); + } const missingUrl = new URL(reportHref); missingUrl.searchParams.set("reportMessageId", "missing-answer"); await page.goto(missingUrl.toString(), { waitUntil: "networkidle" });