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
Original file line number Diff line number Diff line change
Expand Up @@ -1079,6 +1079,7 @@ export function PersonalWorkspacePage({
const composerRef = useRef<HTMLTextAreaElement>(null);
const channelScrollRef = useRef<HTMLDivElement>(null);
const followConversationRef = useRef(true);
const conversationScrollTopRef = useRef(0);
const [showLatestMessage, setShowLatestMessage] = useState(false);
const settingsReturnFocusRef = useRef<HTMLElement | null>(null);
const settingsFromModelMenuRef = useRef(false);
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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 ? <>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 无法读取时,普通发送等待原记录恢复,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 投递或飞书媒体双向同步已通过。
多行草稿在有界输入框中展开;快捷提问留在总览或空对话入口,避免挤占当前对话。

### 图片准入与当前对话位置
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
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.
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading