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
58 changes: 58 additions & 0 deletions apps/web/src/components/chat/AssistantArtifactShelf.browser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import "../../index.css";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { page } from "vitest/browser";
import { describe, expect, it } from "vitest";
import { render } from "vitest-browser-react";

import { serverQueryKeys } from "~/lib/serverReactQuery";

import { AssistantArtifactShelf } from "./AssistantArtifactShelf";

function createQueryClient() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } },
});
queryClient.setQueryData(serverQueryKeys.config(), { availableEditors: [] });
return queryClient;
}

describe("AssistantArtifactShelf", () => {
it("reveals and collapses files after two complete rows and a partial teaser", async () => {
const screen = await render(
<QueryClientProvider client={createQueryClient()}>
<AssistantArtifactShelf
markdown="[One](one.md) [Two](two.md) [Three](three.md) [Four](four.md) [Five](five.md) [Six](six.md) [Seven](seven.md) [Eight](eight.md) [Nine](nine.md) [Ten](ten.md) [Eleven](eleven.md) [Twelve](twelve.md)"
markdownCwd="/study"
workspaceRoot="/study"
/>
</QueryClientProvider>,
);

try {
const showMore = page.getByRole("button", { name: "Show 10 more files" });
await expect.element(showMore).toBeVisible();
await expect.element(showMore).toHaveAttribute("aria-expanded", "false");

const teaser = screen.container.querySelector<HTMLElement>("[inert][aria-hidden='true']");
expect(teaser?.textContent).toContain("Three");
expect(teaser?.matches(":focus-within")).toBe(false);

await showMore.click();
await expect.element(page.getByText("Three", { exact: true })).toBeVisible();
await expect.element(page.getByText("Twelve", { exact: true })).toBeVisible();

const showFewer = page.getByRole("button", { name: "Show fewer files" });
await expect.element(showFewer).toHaveAttribute("aria-expanded", "true");
await showFewer.click();

await expect.element(showMore).toHaveAttribute("aria-expanded", "false");
await expect.element(showMore).toHaveFocus();
expect(
screen.container.querySelector<HTMLElement>("[inert][aria-hidden='true']")?.textContent,
).toContain("Three");
} finally {
await screen.unmount();
}
});
});
42 changes: 39 additions & 3 deletions apps/web/src/components/chat/AssistantArtifactShelf.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ import { describe, expect, it } from "vitest";

import { AssistantArtifactShelf } from "./AssistantArtifactShelf";

function createQueryClient() {
return new QueryClient({
defaultOptions: { queries: { retry: false } },
});
}

describe("AssistantArtifactShelf", () => {
it("shows a pointer cursor on the primary preview surface", () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const queryClient = createQueryClient();
const markup = renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<AssistantArtifactShelf
Expand All @@ -23,4 +27,36 @@ describe("AssistantArtifactShelf", () => {
/<button[^>]*class="[^"]*cursor-pointer[^"]*"[^>]*title="Preview \/study\/topics\/cardiology\/heart-failure\.html"/,
);
});

it("keeps short shelves fully visible", () => {
const markup = renderToStaticMarkup(
<QueryClientProvider client={createQueryClient()}>
<AssistantArtifactShelf
markdown="[One](one.md) [Two](two.md) [Three](three.md)"
markdownCwd="/study"
workspaceRoot="/study"
/>
</QueryClientProvider>,
);

expect(markup).not.toContain("more files");
expect(markup).toContain('title="Preview /study/three.md"');
});

it("shows two complete rows and an inert third-row teaser for long shelves", () => {
const markup = renderToStaticMarkup(
<QueryClientProvider client={createQueryClient()}>
<AssistantArtifactShelf
markdown="[One](one.md) [Two](two.md) [Three](three.md) [Four](four.md)"
markdownCwd="/study"
workspaceRoot="/study"
/>
</QueryClientProvider>,
);

expect(markup).toContain("4 files");
expect(markup).toContain("Show 2 more files");
expect(markup).toContain('aria-expanded="false"');
expect(markup).toMatch(/aria-hidden="true"[^>]*inert=""/);
});
});
110 changes: 98 additions & 12 deletions apps/web/src/components/chat/AssistantArtifactShelf.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,32 @@
import type { EditorId } from "@synara/contracts";
import { isLocalAbsolutePath, joinWorkspaceRelativePath } from "@synara/shared/path";
import { useQuery } from "@tanstack/react-query";
import { memo, useEffect, useMemo, useState } from "react";
import { memo, useEffect, useId, useMemo, useState } from "react";

import { resolveAvailableEditorOptions } from "~/editorMetadata";
import { extractMessageArtifacts, type MessageArtifactReference } from "~/lib/messageArtifacts";
import { AppsIcon, ChevronDownIcon, ExternalLinkIcon, EyeIcon, FolderIcon } from "~/lib/icons";
import {
AppsIcon,
ChevronDownIcon,
ChevronUpIcon,
ExternalLinkIcon,
EyeIcon,
FolderIcon,
} from "~/lib/icons";
import { serverConfigQueryOptions } from "~/lib/serverReactQuery";
import { cn } from "~/lib/utils";
import { openWorkspaceFileReference, useWorkspaceFileOpener } from "~/lib/workspaceFileOpener";
import { readNativeApi } from "~/nativeApi";
import { Button } from "../ui/button";
import { DisclosureRegion } from "../ui/DisclosureRegion";
import { Menu, MenuItem, MenuSeparator, MenuTrigger } from "../ui/menu";
import { toastManager } from "../ui/toast";
import { ComposerPickerMenuPopup } from "./ComposerPickerMenuPopup";
import { FileEntryIcon } from "./FileEntryIcon";

const COLLAPSED_FULL_ARTIFACT_COUNT = 2;
const MIN_ARTIFACT_COUNT_TO_COLLAPSE = 4;

function absoluteArtifactPath(path: string, workspaceRoot: string | undefined): string | null {
if (isLocalAbsolutePath(path)) return path;
return workspaceRoot ? joinWorkspaceRelativePath(workspaceRoot, path) : null;
Expand Down Expand Up @@ -97,6 +108,7 @@ function HtmlArtifactThumbnail(props: {
const AssistantArtifactRow = memo(function AssistantArtifactRow(props: {
artifact: MessageArtifactReference;
workspaceRoot: string | undefined;
loadHtmlThumbnail?: boolean;
}) {
const opener = useWorkspaceFileOpener();
const serverConfigQuery = useQuery(serverConfigQueryOptions());
Expand Down Expand Up @@ -132,7 +144,7 @@ const AssistantArtifactRow = memo(function AssistantArtifactRow(props: {
title={`Preview ${props.artifact.path}`}
onClick={preview}
>
{props.artifact.kind === "html" ? (
{props.artifact.kind === "html" && props.loadHtmlThumbnail !== false ? (
<HtmlArtifactThumbnail
path={props.artifact.path}
label={props.artifact.label}
Expand All @@ -153,7 +165,7 @@ const AssistantArtifactRow = memo(function AssistantArtifactRow(props: {
</span>
</button>

<div className="flex shrink-0 items-center gap-1.5">
<div data-artifact-actions className="flex shrink-0 items-center gap-1.5">
<Button
variant="link"
size="sm"
Expand Down Expand Up @@ -227,25 +239,99 @@ export const AssistantArtifactShelf = memo(function AssistantArtifactShelf(props
markdownCwd: string | undefined;
workspaceRoot: string | undefined;
}) {
const [expanded, setExpanded] = useState(false);
const disclosureId = useId();
const artifacts = useMemo(
() => extractMessageArtifacts(props.markdown, props.markdownCwd),
[props.markdown, props.markdownCwd],
);
if (artifacts.length === 0) return null;

const canCollapse = artifacts.length >= MIN_ARTIFACT_COUNT_TO_COLLAPSE;
const alwaysVisibleArtifacts = canCollapse
? artifacts.slice(0, COLLAPSED_FULL_ARTIFACT_COUNT)
: artifacts;
const disclosedArtifacts = canCollapse ? artifacts.slice(COLLAPSED_FULL_ARTIFACT_COUNT) : [];
const hiddenArtifactCount = disclosedArtifacts.length;
const toggleLabel = expanded
? "Show fewer files"
: `Show ${hiddenArtifactCount} more ${hiddenArtifactCount === 1 ? "file" : "files"}`;

return (
<section className="mt-3 font-system-ui" aria-label="Files cited in this response">
<div className="mb-1.5 px-0.5 text-xs font-medium text-muted-foreground/65">
{artifacts.length === 1 ? "File" : `${artifacts.length} files`}
</div>
<div className="divide-y divide-border/65 overflow-visible rounded-xl border border-border/75 bg-[var(--color-background-elevated-primary)] shadow-xs">
{artifacts.map((artifact) => (
<AssistantArtifactRow
key={artifact.path}
artifact={artifact}
workspaceRoot={props.workspaceRoot}
/>
))}
<div className="overflow-visible rounded-xl border border-border/75 bg-[var(--color-background-elevated-primary)] shadow-xs">
<div className="divide-y divide-border/65">
{alwaysVisibleArtifacts.map((artifact) => (
<AssistantArtifactRow
key={artifact.path}
artifact={artifact}
workspaceRoot={props.workspaceRoot}
/>
))}
</div>

{canCollapse ? (
<div className="relative">
{!expanded ? (
<div className="h-[60px] overflow-hidden border-t border-border/65">
<div
aria-hidden="true"
className="pointer-events-none select-none opacity-55 [&_[data-artifact-actions]]:opacity-0"
inert
>
<AssistantArtifactRow
artifact={disclosedArtifacts[0]!}
workspaceRoot={props.workspaceRoot}
/>
</div>
</div>
) : null}

<DisclosureRegion open={expanded}>
<div
id={disclosureId}
className="divide-y divide-border/65 border-t border-border/65"
>
{disclosedArtifacts.map((artifact) => (
<AssistantArtifactRow
key={artifact.path}
artifact={artifact}
workspaceRoot={props.workspaceRoot}
loadHtmlThumbnail={expanded}
/>
))}
</div>
</DisclosureRegion>

<div
className={cn(
"relative z-10 flex justify-center bg-[var(--color-background-elevated-primary)] px-3 py-2",
expanded
? "border-t border-border/65"
: "absolute inset-x-0 bottom-0 shadow-[0_-12px_18px_var(--color-background-elevated-primary)]",
)}
>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 rounded-full bg-[var(--color-background-elevated-primary)] px-3 text-xs font-medium"
aria-controls={disclosureId}
aria-expanded={expanded}
onClick={() => setExpanded((current) => !current)}
>
{toggleLabel}
{expanded ? (
<ChevronUpIcon aria-hidden="true" className="size-3.5" />
) : (
<ChevronDownIcon aria-hidden="true" className="size-3.5" />
)}
</Button>
</div>
</div>
) : null}
</div>
</section>
);
Expand Down
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.
22 changes: 22 additions & 0 deletions docs/pr-screenshots/assistant-artifact-shelf-collapse/design-qa.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Assistant artifact shelf collapse design QA

## Target

Long file shelves should show two complete cards and a subdued, non-interactive glimpse of the third. A single disclosure control reveals every remaining file and can collapse the shelf again.

## Evidence

- `collapsed-light.png` — 12-file shelf collapsed to two complete rows plus the third-row teaser.
- `collapsed-dark.png` — the same collapsed state in dark mode.

## Checks

- The collapsed 12-file fixture reads `Show 10 more files`.
- The browser interaction fixture expands through the twelfth file and returns to the collapsed state.
- The teaser cannot be clicked, focused, or reached through keyboard navigation.
- The disclosure button preserves focus and exposes `aria-expanded` and `aria-controls`.
- Expansion uses Scient's shared disclosure motion.
- Shelves with three or fewer files remain fully visible without a disclosure control.
- Hidden HTML rows defer rendered thumbnail loading until expansion.

final result: passed
Loading