Skip to content

Commit 7ada6f3

Browse files
authored
Merge pull request #5036 from loopx-project/codex/steward-answer-artifact
Make long Chat answers readable as saved records
2 parents 789ec69 + cbe34b9 commit 7ada6f3

11 files changed

Lines changed: 255 additions & 17 deletions

File tree

‎apps/presentation/dashboard/src/data/chat.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,8 @@ export type ManagerRuntimeSessionReadback = {
675675
};
676676

677677
export type ChatVisibleMessage = {
678+
/** Client-side lineage added when messages from several Sessions are merged. */
679+
session_id?: string;
678680
collaboration?: CollaborationReadback;
679681
origin?: string;
680682
attachments?: ChatImageAttachment[];
@@ -742,7 +744,7 @@ export function mergeChatSessionMessages(snapshots: ChatSessionSnapshot[]) {
742744
const messages = new Map<string, ChatVisibleMessage>();
743745
for (const snapshot of snapshots) {
744746
for (const message of snapshot.messages) {
745-
messages.set(message.message_id, message);
747+
messages.set(message.message_id, { ...message, session_id: snapshot.session.session_id });
746748
}
747749
}
748750
return [...messages.values()].sort((left, right) =>
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
.answer-report-page { min-height: 100dvh; background: #fafafa; color: #171717; padding: 0 20px 64px; }
2+
.answer-report-shell { max-width: 920px; margin: 0 auto; }
3+
.answer-report-header { display: flex; justify-content: space-between; align-items: center; gap: 20px; min-height: 84px; border-bottom: 1px solid #e7e7e7; }
4+
.answer-report-header > div { display: flex; align-items: baseline; flex-wrap: wrap; gap: 12px; }
5+
.answer-report-brand { font-size: 17px; font-weight: 700; letter-spacing: -.04em; }
6+
.answer-report-context { color: #777; font-size: 13px; overflow-wrap: anywhere; }
7+
.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; }
8+
.answer-report-header button:hover, .answer-report-heading button:hover { border-color: #8f8f8f; }
9+
.answer-report-page button:focus-visible, .answer-report-page summary:focus-visible { outline: 2px solid #0070f3; outline-offset: 3px; }
10+
.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; }
11+
.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; }
12+
.answer-report-heading small { color: #666; font-size: 12px; }
13+
.answer-report-heading h1 { font-size: clamp(24px, 3vw, 32px); letter-spacing: -.035em; line-height: 1.25; margin: 6px 0 8px; }
14+
.answer-report-heading p { color: #777; margin: 0; font-size: 12px; }
15+
.answer-report-content { min-width: 0; overflow-wrap: anywhere; }
16+
.answer-report-content .personal-md { display: grid; gap: 18px; font-size: 15px; line-height: 1.8; }
17+
.answer-report-content .personal-md p { margin: 0; font-size: inherit; line-height: inherit; }
18+
.answer-report-content .personal-md-heading.is-h1 { font-size: 24px; font-weight: 650; }
19+
.answer-report-content .personal-md-heading.is-h2 { font-size: 19px; font-weight: 600; }
20+
.answer-report-content .personal-md-table-scroll { max-width: 100%; overflow-x: auto; }
21+
.answer-report-body footer { border-top: 1px solid #ebebeb; margin-top: 40px; padding-top: 16px; color: #666; font-size: 12px; line-height: 1.6; }
22+
.answer-report-body footer summary { cursor: pointer; min-height: 36px; align-content: center; }
23+
.answer-report-body footer dl { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 8px; }
24+
.answer-report-body footer dd { margin: 0; overflow-wrap: anywhere; }
25+
.answer-report-body footer code { font-size: 11px; }
26+
.personal-answer-link { display: inline-block; margin-top: 8px; font-size: 12px; color: var(--pw-blue-ink, #0070f3); text-decoration: none; }
27+
.personal-answer-link:hover { text-decoration: underline; }
28+
.personal-answer-link:focus-visible { outline: 2px solid #0070f3; outline-offset: 3px; }
29+
@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; } }
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import {useEffect, useState} from "react";
2+
3+
import {fetchChatSession, type ChatSessionSnapshot, type ChatVisibleMessage} from "../../data/chat";
4+
import {visibleAgentMessage} from "./answer-text";
5+
import {readWorkspaceLocale} from "./i18n";
6+
import {MarkdownText} from "./markdown";
7+
import "./personal-workspace.css";
8+
import "./goal-loopx-mode.css";
9+
import "./answer-report-page.css";
10+
11+
type ReportState =
12+
| {kind: "loading"}
13+
| {kind: "ready"; snapshot: ChatSessionSnapshot; message: ChatVisibleMessage}
14+
| {kind: "missing"}
15+
| {kind: "error"};
16+
17+
export function AnswerReportPage({sessionId, messageId, statusUrl}: {
18+
sessionId: string;
19+
messageId: string;
20+
statusUrl: string;
21+
}) {
22+
const [state, setState] = useState<ReportState>({kind: "loading"});
23+
const [copied, setCopied] = useState(false);
24+
const [copyFailed, setCopyFailed] = useState(false);
25+
const zh = readWorkspaceLocale() === "zh-CN";
26+
27+
useEffect(() => {
28+
let live = true;
29+
const previousTitle = document.title;
30+
document.title = zh ? "答复记录 · LoopX" : "Answer record · LoopX";
31+
setState({kind: "loading"});
32+
void fetchChatSession(sessionId).then((snapshot) => {
33+
if (!live) return;
34+
const message = snapshot.messages.find((item) => item.message_id === messageId
35+
&& ["agent", "assistant"].includes(item.role));
36+
setState(message ? {kind: "ready", snapshot, message} : {kind: "missing"});
37+
}).catch(() => { if (live) setState({kind: "error"}); });
38+
return () => { live = false; document.title = previousTitle; };
39+
}, [sessionId, messageId, zh]);
40+
41+
const currentUrl = new URL(window.location.href);
42+
currentUrl.searchParams.delete("reportSessionId");
43+
currentUrl.searchParams.delete("reportMessageId");
44+
currentUrl.searchParams.set("goalId", state.kind === "ready" && state.snapshot.session.goal_id !== "loopx-manager"
45+
? state.snapshot.session.goal_id : "");
46+
if (statusUrl) currentUrl.searchParams.set("statusUrl", statusUrl);
47+
const workspaceUrl = currentUrl.toString();
48+
const closeOrReturn = () => {
49+
if (window.opener) window.close();
50+
else window.location.assign(workspaceUrl);
51+
};
52+
53+
const answer = state.kind === "ready" ? state.message : null;
54+
const answerText = answer ? visibleAgentMessage(answer.text) : "";
55+
const sourceQuestion = state.kind === "ready"
56+
? state.snapshot.messages.find((item) => item.turn_id === answer?.turn_id && item.role === "user")
57+
: null;
58+
const created = answer?.created_at ? new Date(answer.created_at) : null;
59+
const dateLabel = created && !Number.isNaN(created.valueOf())
60+
? new Intl.DateTimeFormat(zh ? "zh-CN" : "en", {dateStyle: "medium", timeStyle: "short"}).format(created)
61+
: null;
62+
63+
return <main className="answer-report-page">
64+
<div className="answer-report-shell">
65+
<header className="answer-report-header">
66+
<div><span className="answer-report-brand">LoopX</span><span className="answer-report-context">
67+
{state.kind === "ready" && state.snapshot.session.goal_id !== "loopx-manager"
68+
? state.snapshot.session.goal_id : zh ? "管家" : "Steward"}
69+
</span></div>
70+
<button type="button" onClick={closeOrReturn}>{zh ? "返回对话" : "Back to conversation"}</button>
71+
</header>
72+
<article className="answer-report-body">
73+
<div className="answer-report-heading"><div><small>{zh ? "已保存的答复" : "Saved answer"}</small>
74+
<h1>{zh ? "完整答复" : "Full answer"}</h1>
75+
{dateLabel ? <p><time dateTime={answer?.created_at}>{dateLabel}</time></p> : null}
76+
</div>{answer ? <button type="button" onClick={() => {
77+
setCopyFailed(false);
78+
void navigator.clipboard.writeText(answerText).then(() => setCopied(true)).catch(() => {
79+
setCopied(false);
80+
setCopyFailed(true);
81+
});
82+
}}>{copied ? (zh ? "已复制 Markdown" : "Markdown copied") : (zh ? "复制 Markdown" : "Copy Markdown")}</button> : null}</div>
83+
{copyFailed ? <p role="alert">{zh ? "复制失败。请从正文中选中并复制。" : "Copy failed. Select and copy the answer text instead."}</p> : null}
84+
{state.kind === "loading" ? <p role="status">{zh ? "正在读取已保存的答复…" : "Loading the saved answer…"}</p> : null}
85+
{state.kind === "error" ? <p role="alert">{zh ? "暂时无法读取这份答复;请检查本机 LoopX 服务。" : "Could not load this answer. Check the local LoopX service."}</p> : null}
86+
{state.kind === "missing" ? <p role="alert">{zh ? "找不到这份答复。它可能已被清理,或链接有误。" : "This answer was not found. It may have been removed, or the link may be incorrect."}</p> : null}
87+
{answer ? <>
88+
<div className="answer-report-content"><MarkdownText text={answerText}/></div>
89+
<footer><p>{zh ? "来自本机保存的对话消息;打开此页不会重新运行 Agent。" : "From a locally saved conversation message. Opening this page does not rerun the Agent."}</p>
90+
<details><summary>{zh ? "来源与版本" : "Source and version"}</summary>
91+
<dl><dt>{zh ? "消息" : "Message"}</dt><dd><code>{answer.message_id}</code></dd>
92+
<dt>Session</dt><dd><code>{sessionId}</code></dd></dl>
93+
{sourceQuestion?.text ? <p><strong>{zh ? "原问题:" : "Original question: "}</strong>{sourceQuestion.text}</p> : null}
94+
</details>
95+
</footer>
96+
</> : null}
97+
</article>
98+
</div>
99+
</main>;
100+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/** Keep the saved-answer reader and the original conversation's visible text aligned. */
2+
export const MIN_SEPARATE_ANSWER_LENGTH = 160;
3+
4+
export function visibleAgentMessage(value: string) {
5+
return value
6+
.split(/\r?\n/u)
7+
.filter((line) => !/^\s*GOAL_(STATUS|PROGRESS)\s*:/u.test(line))
8+
.map((line) => {
9+
if (/^\s*GOAL_EVIDENCE\s*:/u.test(line)) return line.replace(/^\s*GOAL_EVIDENCE\s*:/u, "验证依据:");
10+
if (/^\s*NEXT_ACTION\s*:/u.test(line)) return line.replace(/^\s*NEXT_ACTION\s*:/u, "下一步:");
11+
return line;
12+
})
13+
.join("\n")
14+
.trim();
15+
}

‎apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { CollaborationCard } from "./collaboration-card";
44
import { Activity, Bot, Sparkles, Square } from "lucide-react";
55

66
import { AttentionRow } from "./cards/attention-row";
7+
import { MIN_SEPARATE_ANSWER_LENGTH } from "./answer-text";
78
import { MarkdownText } from "./markdown";
89
import { OutputRow } from "./cards/output-row";
910
import { RunRow } from "./cards/run-row";
@@ -13,6 +14,15 @@ import { ReturnDeliveryStatus } from "./return-delivery-status";
1314
import {ManagerTeamResult} from "./manager-team-result";
1415
import type { WorkspaceDrawerSelection, WorkspaceGoal, WorkspaceMessage, WorkspaceTimelineItem } from "./personal-workspace-model";
1516

17+
function answerLink(sessionId: string, messageId: string) {
18+
const url = new URL(window.location.href);
19+
url.searchParams.set("reportSessionId", sessionId);
20+
url.searchParams.set("reportMessageId", messageId);
21+
url.searchParams.set("view", "conversation");
22+
url.hash = "";
23+
return url.toString();
24+
}
25+
1626
function MessageActivity({ message, onInterruptTurn, onSteerTurn }: {
1727
message: WorkspaceMessage;
1828
onInterruptTurn?: (turnId: string) => Promise<void>;
@@ -180,6 +190,11 @@ export function ChannelTimeline({
180190
<header><strong>{item.message.role === "user" ? t("common.you") : item.message.agentLabel ?? t("header.manager")}</strong>{item.message.time ? <time>{item.message.time}</time> : null}</header>
181191
{item.message.attachments?.length ? <div className="personal-message-images">{item.message.attachments.map((attachment) => <img alt={attachment.name} key={attachment.id} src={attachment.dataUrl} />)}</div> : null}
182192
{item.message.role === "user" ? <p>{item.message.text}</p> : item.message.text ? <MarkdownText text={item.message.text} /> : null}
193+
{item.message.role === "assistant" && !item.message.pending && item.message.text.length >= MIN_SEPARATE_ANSWER_LENGTH
194+
&& item.message.sourceSessionId && item.message.sourceMessageId
195+
? <a className="personal-answer-link" href={answerLink(item.message.sourceSessionId, item.message.sourceMessageId)}
196+
target="_blank" rel="noopener noreferrer">{locale === "zh-CN" ? "单独阅读完整答复" : "Read full answer separately"}</a>
197+
: null}
183198
{item.message.role !== "user" && (item.message.pending || item.message.sourceTurnId || item.message.activity?.length) ? <MessageActivity message={item.message} onInterruptTurn={onInterruptTurn} onSteerTurn={onSteerTurn}/> : null}
184199
<CollaborationCard request={item.message.collaboration} />
185200
<ReturnDeliveryStatus delivery={item.message.returnDelivery} />

‎apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,8 @@ export type WorkspaceMessage = {
269269
returnDelivery?: WorkspaceReturnDelivery;
270270
role: "assistant" | "user" | "system";
271271
sourceTurnId?: string;
272+
sourceMessageId?: string;
273+
sourceSessionId?: string;
272274
text: string;
273275
time?: string;
274276
};

‎apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,7 @@ export function PersonalWorkspacePage({
828828
agents = [{ agentId: "codex", available: true, capability: "代码与项目执行", label: "Codex" }],
829829
callbacks = {},
830830
goalArchiveLoadState = { error: null, phase: "ready" },
831+
initialManagerChatOpen = false,
831832
managerChannelBinding,
832833
managerRuntime,
833834
model,
@@ -840,6 +841,7 @@ export function PersonalWorkspacePage({
840841
agents?: WorkspaceAgentOption[];
841842
callbacks?: PersonalWorkspaceCallbacks;
842843
goalArchiveLoadState?: WorkspaceGoalArchiveLoadState;
844+
initialManagerChatOpen?: boolean;
843845
managerChannelBinding?: ManagerChannelBinding | null;
844846
managerRuntime?: ManagerRuntimeSessionReadback | null;
845847
model: WorkspaceModel;
@@ -857,7 +859,7 @@ export function PersonalWorkspacePage({
857859
const [activeSessionRun, setActiveSessionRun] = useState<WorkspaceRun | null>(null);
858860
const [proposals, setProposals] = useState<Record<string, WorkspaceActionPreview>>({});
859861
const [selectedGoalTab, setSelectedGoalTab] = useState<WorkspaceGoalTab>("chat");
860-
const [managerChatOpen, setManagerChatOpen] = useState(false);
862+
const [managerChatOpen, setManagerChatOpen] = useState(initialManagerChatOpen);
861863
const [managerConversationReceiptVisible, setManagerConversationReceiptVisible] = useState(false);
862864
const [goalConversationReceiptVisible, setGoalConversationReceiptVisible] = useState(false);
863865
const [drafts, setDrafts] = useState<Record<string, string>>(() => {

‎apps/presentation/dashboard/src/router.tsx‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,14 @@ import { FrontstageDeveloperPage } from "./views/frontstage-developer-page";
1212
import { useEffect } from "react";
1313
import { resolveLocalStatusUrl } from "./data/local-status-query";
1414
import { BenchmarkStudyPage } from "./views/benchmark-study-page";
15+
import { AnswerReportPage } from "./features/personal-workspace/answer-report-page";
1516

1617
const searchSchema = z.object({
1718
goalId: z.string().optional().default(""),
1819
statusUrl: z.string().optional().default(""),
20+
view: z.literal("conversation").optional(),
21+
reportSessionId: z.string().regex(/^[A-Za-z0-9._-]{1,160}$/).optional(),
22+
reportMessageId: z.string().regex(/^[A-Za-z0-9._-]{1,160}$/).optional(),
1923
});
2024

2125
const frontstageSearchSchema = z.object({
@@ -71,7 +75,12 @@ export const dashboardRoute = createRoute({
7175
getParentRoute: () => rootRoute,
7276
path: "/",
7377
validateSearch: (search) => searchSchema.parse(search),
74-
component: DashboardPage,
78+
component: () => {
79+
const search = dashboardRoute.useSearch();
80+
return search.reportSessionId && search.reportMessageId
81+
? <AnswerReportPage sessionId={search.reportSessionId} messageId={search.reportMessageId} statusUrl={search.statusUrl}/>
82+
: <DashboardPage/>;
83+
},
7584
});
7685

7786
export const frontstageRoute = createRoute({

0 commit comments

Comments
 (0)