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
33 changes: 33 additions & 0 deletions docs/acceptance/chat-return-latest-20261022/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# 长对话返回最新位置验收

任务结束后向上翻看长回复,原页面没有回到底部的入口,只有新内容到达后才显示按钮。现在离开底部时即可使用「回到最新」,有新内容时继续使用原来的提示。搜索定位仍由原来的「返回最新消息」退出历史范围。

## 真实页面

- 前:`a07575e9ee863f94cf65d5add5623d27b7358d69`,PR #454 的前端基线。
- 后:本 PR 的 `conversation.tsx` 更新。
- Windows / Microsoft Edge,1280×900 与 480×600;均为实际生产前端包。
- 同一生产 Go API、隔离工作区、同一持久化对话。通过正常项目导入、创建对话和发送消息,由离线测试模型返回已完成的 48 段回复。没有直接改数据库、接口响应或 DOM。
- 长对话:`thread-run-20261021202227-f4bbfb342eaa`;回复 `evt-20261021202227-73a5553bb0de`。
- 短对话:`thread-run-20261021202227-0d3fe6eef297`。
- API 确认两条回复均已保存,`durable=true`、`provisional=false`,且对应 Agent 回合完成。

基线向上滚动后没有返回按钮。候选页面向上滚动后显示按钮,且未标成有新内容。切换到短对话时按钮消失;切回长对话,阅读位置仍为 1395px,草稿仍在。鼠标点击或窄窗口下聚焦后按 Enter,均回到真实底部(`scrollHeight - clientHeight - scrollTop = 0`),按钮随之隐藏。

截图前将验收草稿清空并关闭无关的浏览器提示,便于比较同一回复位置。

| 场景 | 修改前 | 修改后 |
| --- | --- | --- |
| 桌面翻看已完成回复 | ![修改前](before.png) | ![修改后](after.png) |
| 480×600 | ![窄窗口修改前](before-narrow.png) | ![窄窗口修改后](after-narrow.png) |

实际点击后回到末尾:

![已回到回复末尾](after-latest.png)

## 检查

- 新回归测试在原代码上因找不到「回到最新」失败,修改后通过。
- `npx vitest run src/v2/components/conversation.test.tsx src/v2/use-reply-reading.test.tsx`:67 项通过,包含流式更新不抢走阅读位置、旧记录分页、读取状态检查。
- `npm run build`(含 TypeScript 检查)、`git diff --check` 通过。
- OCR delegate 选出的 1 个产品文件已全部审阅,同时核对测试及 `useV2ReplyReading` 调用,未发现需要修复的问题。
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
103 changes: 52 additions & 51 deletions web/package-lock.json

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

7 changes: 4 additions & 3 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@tiptap/starter-kit": "3.32.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
"dompurify": "3.4.17",
"highlight.js": "11.11.1",
"lucide-react": "1.31.0",
"monaco-editor": "0.56.0",
Expand All @@ -49,16 +50,16 @@
"openapi-typescript": "7.13.0",
"typescript": "5.9.3",
"vite": "8.3.4",
"vitest": "4.1.11"
"vitest": "4.1.12"
},
"overrides": {
"@redocly/openapi-core": {
"js-yaml": "4.3.2"
},
"brace-expansion": "5.0.12",
"dompurify": "3.4.16",
"dompurify": "3.4.17",
"nanoid": "3.3.18",
"postcss": "8.5.25",
"undici": "7.29.1"
"undici": "7.30.1"
}
}
21 changes: 21 additions & 0 deletions web/src/lib/monaco-sanitizer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import DOMPurify from "dompurify";
// @ts-expect-error Monaco ships this runtime sanitizer without declarations.
import { safeSetInnerHtml, sanitizeHtml } from "monaco-editor/base/browser/domSanitize.js";

describe("Monaco sanitizer", () => {
it.each(["trusted HTML", "DOM fragment"])("uses the installed DOMPurify for %s", (output) => {
const sanitize = vi.spyOn(DOMPurify, "sanitize");
const dirty = '<p>Kept <strong>formatting</strong></p><script>alert(1)</script>' +
'<img src="data:," onerror="alert(2)"><a href="javascript:alert(3)">unsafe</a>' +
'<a href="https://example.com">safe</a>';
const target = document.createElement("div");

if (output === "trusted HTML") target.innerHTML = String(sanitizeHtml(dirty));
else safeSetInnerHtml(target, dirty);

expect(sanitize).toHaveBeenCalled();
expect(target.querySelector("strong")).toHaveTextContent("formatting");
expect(target.querySelector('script, [onerror], a[href^="javascript:"]')).toBeNull();
expect(target.querySelector('a[href="https://example.com"]')).toHaveTextContent("safe");
});
});
28 changes: 28 additions & 0 deletions web/src/v2/components/conversation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,34 @@ describe("V2Conversation", () => {
expect(view.container.querySelector<HTMLDivElement>(".v2-conversation-scroll")!.scrollTop).toBe(180);
});

it("offers a return to the latest message while reading a completed chat without new content", async () => {
const view = renderConversation(baseClient());
await screen.findByText("Title thread-a");
await waitFor(() => expect(view.queryClient.getQueryState(v2QueryKeys.transcript("thread-a"))?.status).toBe("success"));
const scroller = view.container.querySelector<HTMLDivElement>(".v2-conversation-scroll")!;
let top = 1500;
Object.defineProperties(scroller, { scrollHeight: { value: 2000, configurable: true },
clientHeight: { value: 500, configurable: true },
scrollTop: { get: () => top, set: (value: number) => { top = Math.min(value, 1500); }, configurable: true } });
fireEvent.scroll(scroller);
expect(screen.queryByRole("button", { name: "回到最新" })).not.toBeInTheDocument();
scroller.scrollTop = 180;
fireEvent.scroll(scroller);
const jump = screen.getByRole("button", { name: "回到最新" });
expect(screen.queryByRole("button", { name: "有新内容 · 回到最新" })).not.toBeInTheDocument();
expect(scroller.scrollTop).toBe(180);
await userEvent.click(jump);
expect(scroller.scrollTop).toBe(1500);
expect(screen.queryByRole("button", { name: "回到最新" })).not.toBeInTheDocument();
expect(view.queryClient.getQueryData(["v2", "reading", "thread-a"])).toEqual({ top: 1500, following: true });
scroller.scrollTop = 120;
fireEvent.scroll(scroller);
expect(screen.getByRole("button", { name: "回到最新" })).toBeInTheDocument();
scroller.scrollTop = 1500;
fireEvent.scroll(scroller);
expect(screen.queryByRole("button", { name: "回到最新" })).not.toBeInTheDocument();
});

it("follows viewport and card size changes without moving a reader looking at history", async () => {
const resize = new Map<Element, () => void>();
vi.stubGlobal("ResizeObserver", class {
Expand Down
14 changes: 11 additions & 3 deletions web/src/v2/components/conversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,7 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana
}, [threadID, messageID, returnToLatestMessage]);
const scrollSizeRef = useRef<{ content: number; viewport: number } | null>(null);
const [hasNewContent, setHasNewContent] = useState(false);
const [awayFromLatest, setAwayFromLatest] = useState(false);
const optimistic = useMemo(() => submissions.filter(({ pending }) => pending).map(({ input }) => ({
id: input.operationKey, text: input.content, images: input.images, attachments: input.attachments, createdAt: input.createdAt,
})), [submissions]);
Expand Down Expand Up @@ -713,6 +714,7 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana
element.scrollTop = saved && !saved.following ? saved.top : element.scrollHeight;
scrollSizeRef.current = { content: element.scrollHeight, viewport: element.clientHeight };
setHasNewContent(false);
setAwayFromLatest(element.scrollHeight - element.clientHeight - element.scrollTop > 48);
return;
}
const anchor = olderScrollAnchorRef.current;
Expand Down Expand Up @@ -762,6 +764,7 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana
if (readingThreadRef.current === threadID && followLatestRef.current &&
olderScrollAnchorRef.current?.threadID !== threadID) element.scrollTop = element.scrollHeight;
scrollSizeRef.current = { content: element.scrollHeight, viewport: element.clientHeight };
setAwayFromLatest(element.scrollHeight - element.clientHeight - element.scrollTop > 48);
});
observer.observe(element);
observer.observe(content);
Expand Down Expand Up @@ -1105,6 +1108,7 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana
scrollSizeRef.current = { content: element.scrollHeight, viewport: element.clientHeight };
const following = element.scrollHeight - element.clientHeight - element.scrollTop <= 48;
followLatestRef.current = following;
setAwayFromLatest(!following);
if (following) setHasNewContent(false);
queryClient.setQueryData(["v2", "reading", threadID], { top: element.scrollTop, following });
reportReplyReading();
Expand Down Expand Up @@ -1180,12 +1184,16 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana
</div>}
</div>
</div>
{view === "conversation" && hasNewContent && <button className="v2-jump-latest" onClick={() => {
{view === "conversation" && !messageID && awayFromLatest && <button className="v2-jump-latest" onClick={() => {
followLatestRef.current = true;
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
queryClient.setQueryData(["v2", "reading", threadID], { top: scrollRef.current.scrollTop, following: true });
}
setAwayFromLatest(false);
setHasNewContent(false);
reportReplyReading();
}} type="button">{t("有新内容 · 回到最新", "New content · Jump to latest")}</button>}
}} type="button">{hasNewContent ? t("有新内容 · 回到最新", "New content · Jump to latest") : t("回到最新", "Jump to latest")}</button>}
<div className="v2-composer-dock">
{(agentActivity || currentRun.status === "paused") && <div className="v2-composer-status">
<V2AgentActivity activity={agentActivity} />
Expand Down
Loading
Loading