Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -742,7 +744,7 @@ export function mergeChatSessionMessages(snapshots: ChatSessionSnapshot[]) {
const messages = new Map<string, ChatVisibleMessage>();
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) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -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; } }
Original file line number Diff line number Diff line change
@@ -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<ReportState>({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 <main className="answer-report-page">
<div className="answer-report-shell">
<header className="answer-report-header">
<div><span className="answer-report-brand">LoopX</span><span className="answer-report-context">
{state.kind === "ready" && state.snapshot.session.goal_id !== "loopx-manager"
? state.snapshot.session.goal_id : zh ? "管家" : "Steward"}
</span></div>
<button type="button" onClick={closeOrReturn}>{zh ? "返回对话" : "Back to conversation"}</button>
</header>
<article className="answer-report-body">
<div className="answer-report-heading"><div><small>{zh ? "已保存的答复" : "Saved answer"}</small>
<h1>{zh ? "完整答复" : "Full answer"}</h1>
{dateLabel ? <p><time dateTime={answer?.created_at}>{dateLabel}</time></p> : null}
</div>{answer ? <button type="button" onClick={() => {
setCopyFailed(false);
void navigator.clipboard.writeText(answerText).then(() => setCopied(true)).catch(() => {
setCopied(false);
setCopyFailed(true);
});
}}>{copied ? (zh ? "已复制 Markdown" : "Markdown copied") : (zh ? "复制 Markdown" : "Copy Markdown")}</button> : null}</div>
{copyFailed ? <p role="alert">{zh ? "复制失败。请从正文中选中并复制。" : "Copy failed. Select and copy the answer text instead."}</p> : null}
{state.kind === "loading" ? <p role="status">{zh ? "正在读取已保存的答复…" : "Loading the saved answer…"}</p> : null}
{state.kind === "error" ? <p role="alert">{zh ? "暂时无法读取这份答复;请检查本机 LoopX 服务。" : "Could not load this answer. Check the local LoopX service."}</p> : null}
{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}
{answer ? <>
<div className="answer-report-content"><MarkdownText text={answerText}/></div>
<footer><p>{zh ? "来自本机保存的对话消息;打开此页不会重新运行 Agent。" : "From a locally saved conversation message. Opening this page does not rerun the Agent."}</p>
<details><summary>{zh ? "来源与版本" : "Source and version"}</summary>
<dl><dt>{zh ? "消息" : "Message"}</dt><dd><code>{answer.message_id}</code></dd>
<dt>Session</dt><dd><code>{sessionId}</code></dd></dl>
{sourceQuestion?.text ? <p><strong>{zh ? "原问题:" : "Original question: "}</strong>{sourceQuestion.text}</p> : null}
</details>
</footer>
</> : null}
</article>
</div>
</main>;
}
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -13,6 +14,15 @@ 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.searchParams.set("view", "conversation");
url.hash = "";
return url.toString();
}

function MessageActivity({ message, onInterruptTurn, onSteerTurn }: {
message: WorkspaceMessage;
onInterruptTurn?: (turnId: string) => Promise<void>;
Expand Down Expand Up @@ -180,6 +190,11 @@ export function ChannelTimeline({
<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>
{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}
{item.message.role === "user" ? <p>{item.message.text}</p> : item.message.text ? <MarkdownText 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
? <a className="personal-answer-link" href={answerLink(item.message.sourceSessionId, item.message.sourceMessageId)}
target="_blank" rel="noopener noreferrer">{locale === "zh-CN" ? "单独阅读完整答复" : "Read full answer separately"}</a>
: null}
{item.message.role !== "user" && (item.message.pending || item.message.sourceTurnId || item.message.activity?.length) ? <MessageActivity message={item.message} onInterruptTurn={onInterruptTurn} onSteerTurn={onSteerTurn}/> : null}
<CollaborationCard request={item.message.collaboration} />
<ReturnDeliveryStatus delivery={item.message.returnDelivery} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,8 @@ export type WorkspaceMessage = {
returnDelivery?: WorkspaceReturnDelivery;
role: "assistant" | "user" | "system";
sourceTurnId?: string;
sourceMessageId?: string;
sourceSessionId?: string;
text: string;
time?: string;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -840,6 +841,7 @@ export function PersonalWorkspacePage({
agents?: WorkspaceAgentOption[];
callbacks?: PersonalWorkspaceCallbacks;
goalArchiveLoadState?: WorkspaceGoalArchiveLoadState;
initialManagerChatOpen?: boolean;
managerChannelBinding?: ManagerChannelBinding | null;
managerRuntime?: ManagerRuntimeSessionReadback | null;
model: WorkspaceModel;
Expand All @@ -857,7 +859,7 @@ export function PersonalWorkspacePage({
const [activeSessionRun, setActiveSessionRun] = useState<WorkspaceRun | null>(null);
const [proposals, setProposals] = useState<Record<string, WorkspaceActionPreview>>({});
const [selectedGoalTab, setSelectedGoalTab] = useState<WorkspaceGoalTab>("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<Record<string, string>>(() => {
Expand Down
11 changes: 10 additions & 1 deletion apps/presentation/dashboard/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,14 @@ 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(""),
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(),
});

const frontstageSearchSchema = z.object({
Expand Down Expand Up @@ -71,7 +75,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
? <AnswerReportPage sessionId={search.reportSessionId} messageId={search.reportMessageId} statusUrl={search.statusUrl}/>
: <DashboardPage/>;
},
});

export const frontstageRoute = createRoute({
Expand Down
Loading
Loading