fix(frontend): keep artifact lists scrollable (#7284)

Use the shared ScrollArea so long artifact lists remain reachable in desktop panels and mobile drawers. Add browser coverage for short and long lists before clicking the final file.

Adapt the ScrollArea approach from LeonSGP43 in bytedance/deer-flow#2076 and verify it against current main for issue #2074.
This commit is contained in:
jianyx1 2026-10-25 14:55:58 +08:00 committed by GitHub
parent a30b362e9c
commit db7f919118
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 88 additions and 3 deletions

View File

@ -2930,7 +2930,7 @@ recording the others. A concurrent scratch-file removal therefore does not
discard the whole snapshot or suppress detection of other produced outputs.
This remains a best-effort scan, not an atomic filesystem snapshot.
Files presented through `present_files` remain part of the thread's artifact state, and the Web UI restores the artifact panel and selected document after a page refresh. When a completed response successfully presents between 2 and 50 files, its final file card also offers one ZIP download. Archive membership comes from the terminal delivery receipt rather than browser-supplied paths, and the ZIP contains the current file versions, which may have changed since the response. The currently selected formal artifact is refreshed once when the run finishes so edits become visible without a manual reload. Existing UTF-8 text artifacts under `/mnt/user-data/outputs` can also be edited and explicitly saved from the panel on Unix and Windows while the thread is idle; saves use content revisions to prevent overwriting agent changes. Source previews also recognize extensionless `Dockerfile` and `Makefile` artifacts by their file names. Unknown file types, including names such as `constructor` and `__proto__`, retain the download fallback.
Files presented through `present_files` remain part of the thread's artifact state, and the Web UI restores the artifact panel and selected document after a page refresh. Long artifact lists scroll within the panel on desktop and in the mobile drawer, keeping every file reachable. When a completed response successfully presents between 2 and 50 files, its final file card also offers one ZIP download. Archive membership comes from the terminal delivery receipt rather than browser-supplied paths, and the ZIP contains the current file versions, which may have changed since the response. The currently selected formal artifact is refreshed once when the run finishes so edits become visible without a manual reload. Existing UTF-8 text artifacts under `/mnt/user-data/outputs` can also be edited and explicitly saved from the panel on Unix and Windows while the thread is idle; saves use content revisions to prevent overwriting agent changes. Source previews also recognize extensionless `Dockerfile` and `Makefile` artifacts by their file names. Unknown file types, including names such as `constructor` and `__proto__`, retain the download fallback.
Unsaved artifact edits survive switching chats, workspace pages, or visiting the
home page and returning in the same browser tab. Drafts stay in memory for the

View File

@ -15,6 +15,7 @@ import {
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Sheet,
SheetContent,
@ -406,13 +407,13 @@ const ChatBoxContent: React.FC<ChatBoxProps> = ({
<header className="shrink-0">
<h2 className="text-lg font-medium">Artifacts</h2>
</header>
<main className="min-h-0 grow">
<ScrollArea className="min-h-0 grow">
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
files={artifacts}
threadId={threadId}
/>
</main>
</ScrollArea>
</div>
)}
</div>

View File

@ -0,0 +1,84 @@
import { expect, test } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
const THREAD_ID = "00000000-0000-0000-0000-000000002074";
for (const viewport of [
{ name: "desktop", width: 1280, height: 720 },
{ name: "narrow", width: 390, height: 844 },
]) {
test.describe(`Artifact list (${viewport.name})`, () => {
test.use({ viewport });
for (const count of [1, 30]) {
test(`can reach and open the last of ${count} files`, async ({
page,
}, testInfo) => {
const artifacts = Array.from(
{ length: count },
(_, index) => `/mnt/user-data/outputs/report-${index + 1}.txt`,
);
const lastPath = artifacts[artifacts.length - 1]!;
const content = `Contents of report ${count}`;
mockLangGraphAPI(page, {
threads: [
{
thread_id: THREAD_ID,
title: "Artifact list scrolling",
artifacts,
messages: [
{ type: "human", id: "human", content: "Create the reports" },
{ type: "ai", id: "ai", content: "The reports are ready." },
],
},
],
});
await page.route(
`**/api/threads/${THREAD_ID}/artifacts${lastPath}`,
(route) =>
route.fulfill({ contentType: "text/plain", body: content }),
);
await page.goto(`/workspace/chats/${THREAD_ID}`);
await page.getByTestId("artifact-trigger").click();
const panel =
viewport.name === "desktop"
? page.locator("#artifacts")
: page.getByRole("dialog", { name: "Artifacts", exact: true });
const firstFile = panel.getByText("report-1.txt", { exact: true });
const lastFile = panel.getByText(`report-${count}.txt`, {
exact: true,
});
await expect(firstFile).toBeInViewport({ ratio: 1 });
if (count > 1) {
await expect(lastFile).not.toBeInViewport();
}
await firstFile.hover();
await page.mouse.wheel(0, 10_000);
try {
// Check reachability before click(), which would auto-scroll and
// hide the missing user-scrollable region on the old layout.
await expect(lastFile).toBeInViewport({ ratio: 1 });
await expect(
panel
.getByRole("heading", { name: "Artifacts", exact: true })
.last(),
).toBeInViewport({ ratio: 1 });
} finally {
const screenshot = testInfo.outputPath(
"artifact-list-after-scroll.png",
);
await page.screenshot({ path: screenshot });
await testInfo.attach("artifact-list-after-scroll", {
path: screenshot,
contentType: "image/png",
});
}
await lastFile.click();
await expect(panel.locator(".cm-content")).toHaveText(content);
});
}
});
}