diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 2a3ad110a9..5dd9d9d910 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -1079,6 +1079,7 @@ export function PersonalWorkspacePage({ const composerRef = useRef(null); const channelScrollRef = useRef(null); const followConversationRef = useRef(true); + const conversationScrollTopRef = useRef(0); const [showLatestMessage, setShowLatestMessage] = useState(false); const settingsReturnFocusRef = useRef(null); const settingsFromModelMenuRef = useRef(false); @@ -1403,16 +1404,36 @@ export function PersonalWorkspacePage({ followConversationRef.current = true; setShowLatestMessage(false); const scroller = channelScrollRef.current; - if (scroller) scroller.scrollTop = scroller.scrollHeight; + if (scroller) { + scroller.scrollTop = scroller.scrollHeight; + conversationScrollTopRef.current = scroller.scrollTop; + } } useEffect(() => { followConversationRef.current = true; + conversationScrollTopRef.current = channelScrollRef.current?.scrollTop ?? 0; setShowLatestMessage(false); }, [conversationKey, selectedAgentId, conversationOpen]); useEffect(() => { - if (!conversationOpen || !followConversationRef.current) return; - const frame = window.requestAnimationFrame(scrollToLatestMessage); - return () => window.cancelAnimationFrame(frame); + if (!conversationOpen) return; + let frame = 0; + const follow = () => { + if (!followConversationRef.current) return; + window.cancelAnimationFrame(frame); + frame = window.requestAnimationFrame(() => { + if (followConversationRef.current) scrollToLatestMessage(); + }); + }; + follow(); + // Images, expanded replies and a resized window can change the viewport + // without a new message. Keep following only while the reader is at the end. + const observer = new ResizeObserver(follow); + const scroller = channelScrollRef.current; + if (scroller) { + observer.observe(scroller); + scroller.querySelectorAll(".personal-channel-timeline").forEach(element => observer.observe(element)); + } + return () => {observer.disconnect(); window.cancelAnimationFrame(frame);}; }, [conversationOpen, conversationKey, selectedAgentId, conversationMessages.length, latestMessageTextLength, latestMessage?.pending, latestMessage?.activity?.length, latestMessage?.steps]); useEffect(() => { @@ -2855,7 +2876,10 @@ export function PersonalWorkspacePage({ if (!conversationOpen) return; const { scrollHeight, scrollTop, clientHeight } = event.currentTarget; const nearBottom = scrollHeight - scrollTop - clientHeight < 64; - followConversationRef.current = nearBottom; + // Growing content or a shorter viewport is not a reader moving up. + if (nearBottom) followConversationRef.current = true; + else if (scrollTop < conversationScrollTopRef.current) followConversationRef.current = false; + conversationScrollTopRef.current = scrollTop; setShowLatestMessage(!nearBottom); }}> {!rootUnavailable && !runtimeSourceUnavailable ? <> diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md index 4421e297a0..bf3a4d4e7d 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md @@ -1288,6 +1288,14 @@ unreadable current Session blocks ordinary send until its exact record recovers; never infer an empty conversation or create a replacement driver from a read failure. A recovered read does not prove receiver adoption or native GQ02/GQ09. +Following the newest reply also survives a resized viewport or timeline, including +image layout and expanded replies, without waiting for another message. Scrolling +up keeps the reader in history across layout changes; the existing latest-message +button resumes following. This stays in the shared conversation view, without a +new session or execution owner. The [synthetic desktop and narrow captures](../../assets/personal-workspace/conversation-layout-follow/README.md) +qualify this layout boundary; they do not establish installed native input, +complete history pagination, provider delivery or bidirectional Lark media. + App 历史恢复由管家与 Goal 对话共用的 TS 读取边界负责。旧 Session 读取失败不应 隐藏可用消息、丢失原结果位置或重启当前流;应明确展示历史不完整,退避重读缺失 快照,并保留实时文本与草稿。当前 Session 无法读取时,普通发送等待原记录恢复, diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md index 679c517f1e..b60789ae40 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md @@ -960,6 +960,10 @@ App 历史与重连读取同一条消息;managed 和 attached 完成存储均 [公开模型评测](../../../examples/evaluations/chat-intake.py) 仅在 release 候选版本验收时显式启用付费调用,默认及新增宣传的模型配置分别至少重复两次。平时 PR、每次提交和 heartbeat 只跑离线回归及受影响的浏览器场景,不运行付费评测。记录候选版本,保留失败;凭证不可用记为跳过,不视为通过。评测使用生产 prompt/parser 和固定公开上下文,覆盖新建、已有负责人、多候选、停止/无授权、纠偏、当前 Goal 追问、引用、否定、普通问答与中英文表达。冲突操作、协议缺失、生成截断均记录为失败。记录模型、prompt/用例哈希、请求参数、token 用量和重复次数,不泄露凭证。该层只验证模型解释已提供证据,不证明真实发现、派发、停止执行或完整 GQ01/GQ02;打包浏览器和实际委派传输测试分别覆盖其边界。 同一对话界面在用户停留底部时跟随输出,上翻阅读后保持位置,并提供回到最新消息的入口。 +窗口或时间线尺寸变化(包括图片布局与展开回复)后仍应跟随最新回复,不必等待下一条消息。 +用户上翻阅读时,布局变化保留当前位置;既有回到最新消息按钮恢复跟随。该行为留在共享 +对话界面,不新增 Session 或执行者。[合成桌面与窄屏截图](../../assets/personal-workspace/conversation-layout-follow/README.md) +只验收布局边界,不代表已安装原生输入、完整历史分页、provider 投递或飞书媒体双向同步已通过。 多行草稿在有界输入框中展开;快捷提问留在总览或空对话入口,避免挤占当前对话。 ### 图片准入与当前对话位置 diff --git a/docs/assets/personal-workspace/conversation-layout-follow/README.md b/docs/assets/personal-workspace/conversation-layout-follow/README.md new file mode 100644 index 0000000000..e106a02a44 --- /dev/null +++ b/docs/assets/personal-workspace/conversation-layout-follow/README.md @@ -0,0 +1,27 @@ +# Conversation layout following / 对话布局跟随 + +These unedited captures use the existing `conversation-image-request` browser +fixture. All messages, operation cards, images and provider responses are synthetic. +No private conversation or real financial operation appears here. The delegation +read failure in the fixture header is intentional and unrelated to this change. + +The desktop viewport is 1512 × 600 and the narrow viewport is 390 × 600. Before +uses the production view from `c56f55307e87110e54d9a82c868d3649280f22fb` with the +new regression: after shrinking the window, the newest reply falls below the +visible area. After uses this change: the newest reply remains visible. The +scenario separately verifies that resizing while reading older history preserves +the reader’s position, and the existing latest-message button resumes following. + +这些原始截图来自既有合成浏览器场景,展示同一共享对话界面的窗口缩放行为。 +源码及打包后的场景验收、已安装原生输入、真实 provider 投递与完整 App/飞书同步分别核验; +截图不证明后三项通过。测试修订及实际通过范围以 PR 验证记录为准。 + +## Before / 修改前 + +![Desktop before shrinking follow repair](before-desktop.png) + +## After / 修改后 + +![Desktop after follow repair](after-desktop.png) + +![Narrow viewport after follow repair](after-narrow.png) diff --git a/docs/assets/personal-workspace/conversation-layout-follow/after-desktop.png b/docs/assets/personal-workspace/conversation-layout-follow/after-desktop.png new file mode 100644 index 0000000000..d8a4248ba7 Binary files /dev/null and b/docs/assets/personal-workspace/conversation-layout-follow/after-desktop.png differ diff --git a/docs/assets/personal-workspace/conversation-layout-follow/after-narrow.png b/docs/assets/personal-workspace/conversation-layout-follow/after-narrow.png new file mode 100644 index 0000000000..e8bba06b46 Binary files /dev/null and b/docs/assets/personal-workspace/conversation-layout-follow/after-narrow.png differ diff --git a/docs/assets/personal-workspace/conversation-layout-follow/before-desktop.png b/docs/assets/personal-workspace/conversation-layout-follow/before-desktop.png new file mode 100644 index 0000000000..7cc707963b Binary files /dev/null and b/docs/assets/personal-workspace/conversation-layout-follow/before-desktop.png differ diff --git a/examples/personal-workspace-browser/conversation-image-request.mjs b/examples/personal-workspace-browser/conversation-image-request.mjs index 3206afe0ec..c297cab710 100644 --- a/examples/personal-workspace-browser/conversation-image-request.mjs +++ b/examples/personal-workspace-browser/conversation-image-request.mjs @@ -76,6 +76,28 @@ export const conversationImageRequestScenario = { assert.ok(box && box.y >= 0 && box.y + box.height <= 982, "Sending keeps the current reply in view"); assert.equal(api.durableWriteCount, 0, "Reading operations and sending a chat message cannot execute an operation"); await page.screenshot({path: resolve(outputDir, `conversation-image-${width}.png`), animations: "disabled"}); + // A shorter window must keep the current reply readable without a new + // message/stream event. Layout changes are not a request to read history. + await page.setViewportSize({width, height: 600}); + try { + await page.waitForFunction(() => { + const scroll = document.querySelector(".personal-channel-scroll"); + return scroll && scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 2; + }, undefined, {timeout: 3000}); + } finally { + await page.screenshot({path: resolve(outputDir, `conversation-image-resized-${width}.png`), animations: "disabled"}); + } + const transcript = page.locator(".personal-channel-scroll"); + assert.ok(await transcript.evaluate(element => element.scrollHeight > element.clientHeight + 100), + "The fixture must have older history outside the viewport"); + await transcript.evaluate(element => {element.scrollTop = 0;}); + await page.getByRole("button", {name: "回到最新消息 ↓", exact: true}).waitFor(); + await page.setViewportSize({width, height: 720}); + await page.evaluate(() => new Promise(resolveFrame => requestAnimationFrame(() => requestAnimationFrame(resolveFrame)))); + assert.equal(await transcript.evaluate(element => element.scrollTop), 0, + "Resizing while reading older history must not pull the reader to the newest reply"); + await page.getByRole("button", {name: "回到最新消息 ↓", exact: true}).click(); + await page.setViewportSize({width, height: 982}); // A confirmed pre-admission rejection retains the exact draft/image. await page.route("**/api/chat/sessions/*/turns", route => route.fulfill({status: 400, json: { ok: false, error: "Synthetic invalid attachment", turn_replay_safe: true, delivery_state: "not_delivered",