diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 2e9f6beac0..922021c26e 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -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 diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index 0051fbfde2..8ddacfa8c8 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -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", diff --git a/apps/presentation/dashboard/smoke/conversation-find-browser-smoke.mjs b/apps/presentation/dashboard/smoke/conversation-find-browser-smoke.mjs new file mode 100644 index 0000000000..46f2575ab1 --- /dev/null +++ b/apps/presentation/dashboard/smoke/conversation-find-browser-smoke.mjs @@ -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(); +} diff --git a/apps/presentation/dashboard/smoke/conversation-history-http-fixture.py b/apps/presentation/dashboard/smoke/conversation-history-http-fixture.py index 2dbfbb3d9c..0fb2710907 100644 --- a/apps/presentation/dashboard/smoke/conversation-history-http-fixture.py +++ b/apps/presentation/dashboard/smoke/conversation-history-http-fixture.py @@ -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) 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 3fe4f5dda2..b9a16bf756 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx @@ -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"; @@ -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) { @@ -69,6 +70,20 @@ export function ChannelTimeline({ onAdjustTurn?: (turnId: string) => void; }) { const { locale, t } = useWorkspaceI18n(); + const [findNavigation, setFindNavigation] = useState(0); + const [findMessageId, setFindMessageId] = useState(null); + const timeline = useRef(null); + const readMessage = useEffectEvent(() => onReadMessage?.()); + useLayoutEffect(() => { + if (!findMessageId) return; + readMessage(); + const frame = requestAnimationFrame(() => { + const row = Array.from(timeline.current?.querySelectorAll("[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; @@ -78,8 +93,7 @@ export function ChannelTimeline({ && messageFocus?.messageId === item.message.sourceMessageId; return !queued || focused; })); - const messageKey = (message: Extract["message"]) => - JSON.stringify([message.sourceSessionId, message.sourceMessageId]); + const messageKey = conversationMessageKey; const [historical, setHistorical] = useState | 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. @@ -200,7 +214,8 @@ export function ChannelTimeline({ ); } return ( -
{item.message.attachments.map((attachment) => isDocumentAttachment(attachment.mimeType) ? : {attachment.name})} : null} {item.message.role === "user" ?

{item.message.text}

: item.message.text ? : 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} {item.message.role === "assistant" && !item.message.pending && item.message.text.length >= MIN_SEPARATE_ANSWER_LENGTH && item.message.sourceSessionId && item.message.sourceMessageId @@ -233,7 +249,9 @@ export function ChannelTimeline({ <>

{liveAnnouncement}

{focusNotice} -
+ {orderedItems.some(item => item.kind === "message") ? item.kind === "message" ? [item.message] : [])} + incomplete={historyPhase !== "ready"} zh={locale === "zh-CN"} onSelect={setFindMessageId} onNavigate={() => setFindNavigation(value => value + 1)}/> : null} +
{backgroundIds.size ?
{scheduleItems.map(renderItem)}{routineRuns.map(renderItem)}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/conversation-find.css b/apps/presentation/dashboard/src/features/personal-workspace/conversation-find.css new file mode 100644 index 0000000000..cb69deb561 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/conversation-find.css @@ -0,0 +1,9 @@ +.personal-conversation-find {position:sticky;top:0;z-index:2;background:var(--pw-bg);padding:8px 0;border-bottom:1px solid var(--pw-line);} +.personal-conversation-find button {display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:36px;padding:6px 8px;border:1px solid var(--pw-line);border-radius:6px;color:var(--pw-text);background:var(--pw-bg);font:inherit;cursor:pointer;} +.personal-conversation-find button:disabled {opacity:.45;cursor:default;} +.personal-conversation-find form {display:flex;align-items:center;flex-wrap:wrap;gap:6px;margin-top:8px;} +.personal-conversation-find input {flex:1;min-width:120px;max-width:420px;padding:8px;border:1px solid var(--pw-line);border-radius:6px;color:var(--pw-text);background:var(--pw-bg);font:inherit;} +.personal-conversation-find [role=status] {font-size:12px;} +.personal-conversation-find small {flex-basis:100%;} +.personal-message.is-find-match {outline:2px solid var(--pw-text);outline-offset:4px;border-radius:6px;scroll-margin-top:140px;} +@media(max-width:640px) {.personal-conversation-find input {flex-basis:100%;max-width:none;}.personal-conversation-find button {min-height:40px;}.personal-message.is-find-match {scroll-margin-top:190px;}} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/conversation-find.tsx b/apps/presentation/dashboard/src/features/personal-workspace/conversation-find.tsx new file mode 100644 index 0000000000..1e3806d819 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/conversation-find.tsx @@ -0,0 +1,60 @@ +import {ArrowDown, ArrowUp, Search, X} from "lucide-react"; +import {useLayoutEffect, useRef, useState} from "react"; +import type {WorkspaceMessage} from "./personal-workspace-model"; +import "./conversation-find.css"; + +export function conversationMessageKey(message: WorkspaceMessage): string { + return JSON.stringify([message.sourceSessionId ?? null, message.sourceMessageId ?? message.id]); +} + +/** Search only the original text already read by the conversation owner. */ +export function ConversationFind({items, incomplete, zh, onSelect, onNavigate}: { + items: WorkspaceMessage[]; + incomplete: boolean; + zh: boolean; + onSelect: (id: string | null) => void; + onNavigate: () => void; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState(null); + const trigger = useRef(null); + const needle = query.trim().toLocaleLowerCase(); + const matches = open && needle ? items.filter(message => message.text.toLocaleLowerCase().includes(needle)) : []; + // Retain the selected original message when an older page or live reply arrives. + const index = Math.max(0, matches.findIndex(item => conversationMessageKey(item) === selected)); + const activeId = matches[index] ? conversationMessageKey(matches[index]) : null; + useLayoutEffect(() => {setSelected(activeId); onSelect(activeId);}, [activeId, selected, onSelect]); + function close() { + setOpen(false); setQuery(""); setSelected(null); onSelect(null); + trigger.current?.focus(); + } + function move(offset: number) { + if (!matches.length) return; + setSelected(conversationMessageKey(matches[(index + offset + matches.length) % matches.length])); + onNavigate(); + } + return
+ + {open ?
{event.preventDefault(); move(1);}} + onKeyDown={event => { + if (event.nativeEvent.isComposing) return; + if (event.key === "Escape") {event.preventDefault(); event.stopPropagation(); close();} + else if (event.key === "Enter" && event.shiftKey) {event.preventDefault(); move(-1);} + }}> + {setQuery(event.target.value); setSelected(null);}}/> + {needle ? matches.length + ? `${index + 1} / ${matches.length}` : (zh ? "没有匹配消息" : "No matching messages") + : (zh ? "仅查找已加载文本" : "Loaded text only")} + + + + {incomplete ? {zh ? "对话记录尚未完整读取,结果仅包含已加载消息。" : "History is incomplete; results include loaded messages only."} : null} +
: null} +
; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/message-reading.tsx b/apps/presentation/dashboard/src/features/personal-workspace/message-reading.tsx index 2227f344d8..a55d188955 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/message-reading.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/message-reading.tsx @@ -1,4 +1,4 @@ -import {useEffect, useId, useLayoutEffect, useMemo, useRef, useState} from "react"; +import {useId, useLayoutEffect, useMemo, useRef, useState} from "react"; import {MarkdownText} from "./markdown"; import {markdownExcerpt} from "./markdown-blocks"; import "./message-reading.css"; @@ -15,10 +15,11 @@ export function returnedAnswerStart(scroller: HTMLElement): {key: string; top: n } /** Folding is an explicit reading choice after the initial historical snapshot. */ -export function HistoricalMessageText({text, initiallyCollapsed, focused, zh, onRead}: { +export function HistoricalMessageText({text, initiallyCollapsed, focused, focusRequest, zh, onRead}: { text: string; initiallyCollapsed: boolean; focused: boolean; + focusRequest?: number; zh: boolean; onRead?: () => void; }) { @@ -27,7 +28,7 @@ export function HistoricalMessageText({text, initiallyCollapsed, focused, zh, on const [expanded, setExpanded] = useState(!initiallyCollapsed || focused); const toggle = useRef(null); const anchor = useRef(null); - useEffect(() => {if (focused) setExpanded(true);}, [focused]); + useLayoutEffect(() => {if (focused) setExpanded(true);}, [focused, focusRequest]); useLayoutEffect(() => { if (anchor.current === null || !toggle.current) return; const scroller = toggle.current.closest(".personal-channel-scroll"); diff --git a/docs/guides/assets/conversation-find/desktop.png b/docs/guides/assets/conversation-find/desktop.png new file mode 100644 index 0000000000..ef18b916e8 Binary files /dev/null and b/docs/guides/assets/conversation-find/desktop.png differ diff --git a/docs/guides/assets/conversation-find/mobile.png b/docs/guides/assets/conversation-find/mobile.png new file mode 100644 index 0000000000..303d0d0588 Binary files /dev/null and b/docs/guides/assets/conversation-find/mobile.png differ diff --git a/docs/guides/personal-workspace-user-guide.md b/docs/guides/personal-workspace-user-guide.md index d82d2ec9d5..e30aa182ce 100644 --- a/docs/guides/personal-workspace-user-guide.md +++ b/docs/guides/personal-workspace-user-guide.md @@ -570,6 +570,31 @@ The local Chat HTTP read is `GET /api/chat/delivery-review?goal_id=example-goal` Lark continues using its existing Goal Channel projection; this slice adds no Lark card or notification and does not qualify cross-channel presentation parity. +### 查找先前的要求和答复 / Find earlier instructions and answers + +在管家或 Goal 的**对话**中点击 **查找对话 / Find in conversation**,输入原消息中的 +文字。查找不区分大小写,支持中文;数字表示匹配消息的位置和总数,同一消息中的 +多次出现只算一条。使用箭头或 Enter / Shift+Enter 跳到下一条 / 上一条,匹配的 +历史长回复会展开原文。Esc 关闭查找并将键盘焦点还给入口,切换对话后查询会清空。 + +查找只检查当前已加载的消息文本,不搜索附件内容、工具输出或未读取的会话。 +历史读取失败或仍在加载时,提示会明确说明范围;沿原历史提示重试读取,补载后 +仍保留正在查看的原消息。没有匹配不代表尚未读取的历史中没有结果。 +查找不会发送消息、创建 Turn 或调用模型,也不会把查询存到服务器。 + +English: **Find in conversation** locates matching loaded messages, including full +text behind folded answers. Enter / Shift+Enter navigate; Esc closes the reader. +Results are case-insensitive literal matches, one per message. Attachments, tool +output and unread history are outside the search. Retry failed history reads from +the existing history notice; new pages preserve the selected original message. +Searching does not send messages, start a Turn or persist the query. + +下图为合成会话示例。桌面保留周围消息,手机上输入框独占一行,箭头仍可直接操作。 + +![Find an earlier instruction in the desktop conversation](assets/conversation-find/desktop.png) + +Find controls and the selected original message on a narrow screen + ## 💬 5. 悬浮会话托盘(ManagerConversationTray) 无论你在浏览总览还是在处理看板,只要点击带有 **`立即发送`** 标识的快捷指令,页面右下角都会弹出抽屉式的轻量对话托盘: