Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ jobs:
npm run smoke:chat-upgrade
npm run smoke:delegation-search
npm run smoke:task-inspector-route
npm run smoke:conversation-find
- name: Qualify packaged team evidence and feedback recovery
if: matrix.shard == 2
working-directory: apps/presentation/dashboard
Expand Down
1 change: 1 addition & 0 deletions apps/presentation/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"license": "Apache-2.0",
"type": "module",
"scripts": {
"smoke:conversation-find": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/conversation-find-browser-smoke.mjs",
"smoke:queue-input-recovery": "vite build --ssr smoke/queue-input-recovery-smoke.mjs --outDir node_modules/.cache/queue-input-recovery --emptyOutDir && node node_modules/.cache/queue-input-recovery/queue-input-recovery-smoke.js",
"smoke:todo-ownership": "vite build --ssr smoke/todo-ownership-smoke.mjs --outDir node_modules/.cache/todo-ownership --emptyOutDir && node node_modules/.cache/todo-ownership/todo-ownership-smoke.js",
"smoke:workspace-progressive-loader": "node smoke/workspace-progressive-status-smoke.mjs",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Packaged reader, production HTTP/store, synthetic messages; no model calls.
import assert from "node:assert/strict";
import {spawn} from "node:child_process";
import {once} from "node:events";
import {createInterface} from "node:readline";
import {mkdir} from "node:fs/promises";
import {resolve} from "node:path";
import {resolveTestPython} from "../../../../scripts/test-python.mjs";
import {launchBrowser, loadPlaywright, waitForHttp} from "../../../../examples/dashboard-browser-smoke-support.mjs";

process.env.LOOPX_PERSONAL_WORKSPACE_PACKAGED = "1";
const {repoRoot, outputDir, port, startServer} = await import("../../../../examples/personal-workspace-browser/fixture.mjs");
const {openWorkspacePage} = await import("../../../../examples/personal-workspace-browser/scenario-context.mjs");
const child = spawn(resolveTestPython({repoRoot}), ["-u", "apps/presentation/dashboard/smoke/conversation-history-http-fixture.py", "--manager", "--find"],
{cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"]});
const exited = once(child, "exit"), lines = createInterface({input: child.stdout});
const iterator = lines[Symbol.asyncIterator]();
let stderr = "", browser, server, workspace;
child.stderr.on("data", data => {stderr += String(data);});
async function next() {const row = await iterator.next(); assert.equal(row.done, false, stderr); return JSON.parse(row.value);}
async function command(value) {child.stdin.write(`${value}\n`); return next();}
try {
const {origin} = await next();
await command("recover");
server = await startServer();
const url = `http://127.0.0.1:${port}/chat/?statusUrl=/status.json`;
await waitForHttp(url);
browser = await launchBrowser(loadPlaywright().chromium);
for (const locale of ["en", "zh-CN"]) {
const zh = locale === "zh-CN";
await command("fault");
workspace = await openWorkspacePage(browser, url, {beforeGoto(_api, page) {
return Promise.all([page.addInitScript(value => localStorage.setItem("loopx-pw-locale", value), locale),
page.route("**/api/chat/sessions**", async route => {
const request = route.request(), target = new URL(request.url());
if (!/^\/api\/chat\/sessions(?:\/(?:old|current|other-channel))?$/u.test(target.pathname)) return route.fallback();
if (request.method() === "POST" && target.pathname === "/api/chat/sessions") {
if (request.postDataJSON().context_kind !== "manager") return route.fallback();
const response = await route.fetch({url: `${origin}/api/chat/sessions/current`, method: "GET", postData: undefined});
const snapshot = await response.json();
return route.fulfill({status: 201, json: {ok: true, resumed: true, session_id: "current", session: snapshot.session}});
}
assert.equal(request.method(), "GET", "Finding original messages cannot mutate the store");
const response = await route.fetch({url: `${origin}${target.pathname}${target.search}`});
return route.fulfill({response});
})]);
}});
const {page, api} = workspace;
await page.getByRole("navigation", {name: zh ? "管家视图" : "Manager view"}).getByRole("button", {name: /^(Chat|对话)$/}).click();
const toggle = page.getByRole("button", {name: zh ? "查找对话" : "Find in conversation", exact: true});
await toggle.click();
const input = page.getByRole("searchbox", {name: zh ? "查找已加载消息" : "Find in loaded messages"});
await input.fill("lunar");
const bar = page.locator(".personal-conversation-find"), active = page.locator(".personal-message.is-find-match");
await bar.getByRole("status").filter({hasText: "1 / 2"}).waitFor();
await bar.getByText(zh ? "对话记录尚未完整读取,结果仅包含已加载消息。" : "History is incomplete; results include loaded messages only.").waitFor();
const retained = await active.getAttribute("data-timeline-id");
await command("recover");
await page.getByRole("button", {name: zh ? "重试读取" : "Retry history", exact: true}).click();
await bar.getByRole("status").filter({hasText: "2 / 3"}).waitFor();
assert.equal(await active.getAttribute("data-timeline-id"), retained, "Backfill preserves the selected original message");
// Reopen the timeline after the complete read to exercise folded history.
await page.locator(".personal-goal-link").first().click();
await page.locator(".personal-manager-link").click();
await page.getByRole("navigation", {name: zh ? "管家视图" : "Manager view"}).getByRole("button", {name: /^(Chat|对话)$/}).click();
await toggle.click(); await input.fill("lunar");
await bar.getByRole("status").filter({hasText: "1 / 3"}).waitFor();
await active.getByText("Keep the lunar evidence. 保留原始证据。", {exact: true}).waitFor();
assert.equal(await active.getByRole("button", {name: zh ? "收起消息" : "Collapse message"}).getAttribute("aria-expanded"), "true");
const first = await active.getAttribute("data-timeline-id");
await input.dispatchEvent("keydown", {key: "Escape", isComposing: true});
assert.equal(await input.count(), 1, "IME composition does not close the reader");
await input.press("Enter");
await bar.getByRole("status").filter({hasText: "2 / 3"}).waitFor();
assert.notEqual(await active.getAttribute("data-timeline-id"), first);
await input.press("Shift+Enter");
await bar.getByRole("status").filter({hasText: "1 / 3"}).waitFor();
await page.waitForFunction(key => document.querySelector(".is-find-match")?.getAttribute("data-timeline-id") === key, first);
await page.getByRole("button", {name: zh ? "上一条匹配消息" : "Previous matching message"}).click();
await bar.getByRole("status").filter({hasText: "3 / 3"}).waitFor();
await input.fill("保留原始证据");
await bar.getByRole("status").filter({hasText: "1 / 1"}).waitFor();
for (const width of [1200, 390]) {
await page.setViewportSize({width, height: 844});
// Leave the long original expanded so the old short message can be scrolled away.
await input.fill("Earlier public report");
await bar.getByRole("status").filter({hasText: "1 / 1"}).waitFor();
const inViewport = () => page.waitForFunction(() => {
const row = document.querySelector(".is-find-match")?.getBoundingClientRect();
const viewport = document.querySelector(".personal-channel-scroll")?.getBoundingClientRect();
return row && viewport && row.top >= viewport.top && row.bottom <= viewport.bottom;
}, undefined, {timeout: 5000});
await inViewport();
for (const key of ["Enter", "Shift+Enter", "arrow"]) {
await page.locator(".personal-channel-scroll").evaluate(node => {node.scrollTop = node.scrollHeight;});
assert(await active.evaluate(node => node.getBoundingClientRect().bottom < node.closest(".personal-channel-scroll").getBoundingClientRect().top), "The unique old message is outside the viewport before navigating again");
if (key === "arrow") await bar.getByRole("button", {name: zh ? "上一条匹配消息" : "Previous matching message"}).click();
else await input.press(key);
await inViewport();
}
await input.fill("保留原始证据");
for (const action of ["arrow", "Shift+Enter"]) {
await active.getByRole("button", {name: zh ? "收起消息" : "Collapse message", exact: true}).click();
await active.getByRole("button", {name: zh ? "展开完整消息" : "Read full message", exact: true}).waitFor();
if (action === "arrow") await bar.getByRole("button", {name: zh ? "下一条匹配消息" : "Next matching message"}).click();
else await input.press(action);
await active.getByText("Keep the lunar evidence. 保留原始证据。", {exact: true}).waitFor({timeout: 5000});
assert.equal(await active.getByRole("button", {name: zh ? "收起消息" : "Collapse message"}).getAttribute("aria-expanded"), "true");
}
}
await page.setViewportSize({width: 1200, height: 844});
await mkdir(outputDir, {recursive: true});
// Short matching messages keep the useful reading context in the preview.
await input.fill("final report");
await bar.getByRole("status").filter({hasText: "1 / 1"}).waitFor();
await page.getByRole("button", {name: zh ? "收起消息" : "Collapse message", exact: true}).click();
await input.fill(""); await input.fill("final report");
await active.getByText("Please retain LUNAR evidence in the final report.", {exact: true}).waitFor();
await page.screenshot({path: resolve(outputDir, `conversation-find-${locale}-desktop.png`), animations: "disabled"});
await page.setViewportSize({width: 390, height: 844});
assert(await page.locator("body").evaluate(body => body.scrollWidth <= innerWidth + 1));
await page.screenshot({path: resolve(outputDir, `conversation-find-${locale}-mobile.png`), animations: "disabled"});
await input.fill("[no such literal message]");
await bar.getByRole("status").filter({hasText: zh ? "没有匹配消息" : "No matching messages"}).waitFor();
assert.equal(await active.count(), 0);
assert(await page.getByRole("button", {name: zh ? "下一条匹配消息" : "Next matching message"}).isDisabled());
await input.press("Escape");
assert(await toggle.evaluate(button => button === document.activeElement));
assert.equal(await input.count(), 0);
await page.setViewportSize({width: 1512, height: 982});
await toggle.click(); await input.fill("lunar");
await page.locator(".personal-goal-link").first().click();
await page.locator(".personal-manager-link").click();
await page.getByRole("navigation", {name: zh ? "管家视图" : "Manager view"}).getByRole("button", {name: /^(Chat|对话)$/}).click();
assert.equal(await input.count(), 0, "Changing the conversation discards its private search");
assert.equal(api.turnRequests.length, 0);
assert.deepEqual(await command("inspect"), {store_unchanged: true, turn_count: 0});
await workspace.close(); workspace = null;
}
console.log("conversation-find: packaged HTTP/store, en/zh, full-text expansion, navigation/wrap, literal no-match, scope reset, narrow layout and zero writes passed");
} finally {
await workspace?.close(); await browser?.close(); server?.kill("SIGTERM");
child.stdin.end(); if (child.exitCode === null) await exited; lines.close();
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ def main() -> None:
store.create_session(goal_id="research", agent_id="codex", adapter_kind="codex_app_server",
upstream_thread_id=session_id, channel_id=channel, session_id=session_id)
store.append_message(session_id, role="agent", text=text, message_id="answer")
if "--find" in sys.argv:
store.append_message("old", role="agent", message_id="long-evidence",
text="## Earlier acceptance decision\n\n" + "Original reviewed material. " * 300
+ "\n\nKeep the lunar evidence. 保留原始证据。")
store.append_message("current", role="user", message_id="correction",
text="Please retain LUNAR evidence in the final report.")
store.append_message("current", role="agent", message_id="latest",
text="The lunar evidence is retained. Awaiting the next independent check.")
before = {str(path.relative_to(store.sessions_root)): path.read_bytes()
for path in store.sessions_root.rglob("*") if path.is_file()}
server = ChatHTTPServer(("127.0.0.1", 0), HistoryReadFault)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {DocumentAttachment, isDocumentAttachment} from "./document-attachment";
import { MessageActivity } from "./message-activity";
import { GoalDraftCard } from "./goal-draft-card";
import type { GoalDraft } from "../../../../../../loopx/control_plane/collaboration/goal_draft.js";
import {Fragment, useLayoutEffect, useRef, useState} from "react";
import {Fragment, useEffectEvent, useLayoutEffect, useRef, useState} from "react";
import { CollaborationCards } from "./collaboration-card";
import {ConversationReplyContext} from "./conversation-requests";
import { Activity, Bot, Sparkles } from "lucide-react";
Expand All @@ -19,6 +19,7 @@ import { ReturnDeliveryStatus } from "./return-delivery-status";
import {ManagerTeamResult} from "./manager-team-result";
import { compareProposalRecency } from "./proposal-recency";
import { conversationOrder } from "./conversation-order";
import { ConversationFind, conversationMessageKey } from "./conversation-find";
import type { WorkspaceDrawerSelection, WorkspaceGoal, WorkspaceTimelineItem } from "./personal-workspace-model";

function answerLink(sessionId: string, messageId: string) {
Expand Down Expand Up @@ -69,6 +70,20 @@ export function ChannelTimeline({
onAdjustTurn?: (turnId: string) => void;
}) {
const { locale, t } = useWorkspaceI18n();
const [findNavigation, setFindNavigation] = useState(0);
const [findMessageId, setFindMessageId] = useState<string | null>(null);
const timeline = useRef<HTMLDivElement>(null);
const readMessage = useEffectEvent(() => onReadMessage?.());
useLayoutEffect(() => {
if (!findMessageId) return;
readMessage();
const frame = requestAnimationFrame(() => {
const row = Array.from(timeline.current?.querySelectorAll<HTMLElement>("[data-timeline-id]") ?? [])
.find(node => node.dataset.timelineId === findMessageId);
row?.scrollIntoView({block: "start"});
});
return () => cancelAnimationFrame(frame);
}, [findMessageId, findNavigation]);
const orderedItems = conversationOrder(items.filter(item => {
if (item.kind !== "message") return true;
if (item.message.queued) return false;
Expand All @@ -78,8 +93,7 @@ export function ChannelTimeline({
&& messageFocus?.messageId === item.message.sourceMessageId;
return !queued || focused;
}));
const messageKey = (message: Extract<WorkspaceTimelineItem, {kind: "message"}>["message"]) =>
JSON.stringify([message.sourceSessionId, message.sourceMessageId]);
const messageKey = conversationMessageKey;
const [historical, setHistorical] = useState<Set<string> | null>(null);
// Freeze only the first settled historical batch. A live or newly completed
// response, or a message already being read, never folds on a later refresh.
Expand Down Expand Up @@ -200,7 +214,8 @@ export function ChannelTimeline({
);
}
return (
<article className={`personal-message is-${item.message.role}`} key={item.id}
<article className={`personal-message is-${item.message.role}${messageKey(item.message) === findMessageId ? " is-find-match" : ""}`} key={item.id}
data-timeline-id={messageKey(item.message)}
data-message-session={item.message.sourceSessionId} data-message-id={item.message.sourceMessageId}
data-returned-answer={item.message.role === "assistant" && !item.message.pending && item.message.returnDelivery ? true : undefined}
ref={messageFocus && messageKey(item.message) === focusKey ? focusTarget : undefined}
Expand All @@ -213,7 +228,8 @@ export function ChannelTimeline({
{item.message.attachments?.length ? <div className="personal-message-images">{item.message.attachments.map((attachment) => isDocumentAttachment(attachment.mimeType) ? <DocumentAttachment key={attachment.id} {...attachment} zh={locale === "zh-CN"}/> : <img alt={attachment.name} key={attachment.id} src={attachment.dataUrl} />)}</div> : null}
{item.message.role === "user" ? <p>{item.message.text}</p> : item.message.text ? <HistoricalMessageText text={item.message.text}
initiallyCollapsed={Boolean(historical?.has(messageKey(item.message)))}
focused={Boolean(messageFocus && messageKey(item.message) === focusKey)} zh={locale === "zh-CN"} onRead={onReadMessage}/> : null}
focusRequest={messageKey(item.message) === findMessageId ? findNavigation : undefined}
focused={messageKey(item.message) === findMessageId || Boolean(messageFocus && messageKey(item.message) === focusKey)} zh={locale === "zh-CN"} onRead={onReadMessage}/> : null}
<ConversationReplyContext context={item.message.replyContext} zh={locale === "zh-CN"}/>
{item.message.role === "assistant" && !item.message.pending && item.message.text.length >= MIN_SEPARATE_ANSWER_LENGTH
&& item.message.sourceSessionId && item.message.sourceMessageId
Expand All @@ -233,7 +249,9 @@ export function ChannelTimeline({
<>
<p aria-atomic="true" aria-live="polite" className="personal-live-region" role="status"><span key={latestAnnounceable?.id}>{liveAnnouncement}</span></p>
{focusNotice}
<div className="personal-channel-timeline">
{orderedItems.some(item => item.kind === "message") ? <ConversationFind items={orderedItems.flatMap(item => item.kind === "message" ? [item.message] : [])}
incomplete={historyPhase !== "ready"} zh={locale === "zh-CN"} onSelect={setFindMessageId} onNavigate={() => setFindNavigation(value => value + 1)}/> : null}
<div className="personal-channel-timeline" ref={timeline}>
{backgroundIds.size ? <details className="personal-activity-summary">
<summary><Activity size={16} aria-hidden="true"/><strong>{t("timeline.background")}</strong><span>{activitySummary}</span></summary>
<div>{scheduleItems.map(renderItem)}{routineRuns.map(renderItem)}</div>
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading