Skip to content
Open
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
@@ -1,8 +1,9 @@
import { ConversationSidePanelContext } from "@app/components/assistant/conversation/ConversationSidePanelContext";
import { FilePreviewProvider } from "@app/components/assistant/conversation/FilePreviewContext";
import { getFilePreviewMarkdownDirective } from "@app/lib/markdown/file_preview";
import { LightWorkspaceFactory } from "@app/tests/utils/LightWorkspaceFactory";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";

import { AgentMessageMarkdown } from "./AgentMessageMarkdown";

Expand Down Expand Up @@ -112,15 +113,32 @@ describe("AgentMessageMarkdown - Integration Tests", () => {
});

it("renders scoped file preview directives as previewable files", async () => {
const openPanel = vi.fn();
const { container } = render(
<FilePreviewProvider owner={mockOwner}>
<AgentMessageMarkdown
owner={mockOwner}
content={
'Open :preview_file{path="conversation-c1/booklet.pdf" title="booklet.pdf" contentType="application/pdf"} for the details.'
}
/>
</FilePreviewProvider>
<ConversationSidePanelContext.Provider
value={{
currentPanel: undefined,
isPanelClosing: false,
openPanel,
togglePanel: vi.fn(),
closePanel: vi.fn(),
onPanelClosed: vi.fn(),
setPanelRef: vi.fn(),
panelRef: { current: null },
setVirtuosoMsg: vi.fn(),
virtuosoMsg: null,
data: undefined,
}}
>
<FilePreviewProvider owner={mockOwner}>
<AgentMessageMarkdown
owner={mockOwner}
content={
'Open :preview_file{path="conversation-c1/booklet.pdf" title="booklet.pdf" contentType="application/pdf"} for the details.'
}
/>
</FilePreviewProvider>
</ConversationSidePanelContext.Provider>
);

const fileLink = screen.getByRole("button", { name: "booklet.pdf" });
Expand All @@ -131,10 +149,12 @@ describe("AgentMessageMarkdown - Integration Tests", () => {

fireEvent.click(fileLink);

expect(await screen.findByRole("dialog")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Download" })
).toBeInTheDocument();
await waitFor(() => {
expect(openPanel).toHaveBeenCalledWith({
type: "file_preview",
filePath: "conversation-c1/booklet.pdf",
});
});
});

it("hides incomplete text directives while content is streaming", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ const ConversationLayoutContent = ({

return (
<AssistantLayout owner={owner} user={user} conversation={conversation}>
<FilePreviewProvider owner={owner}>
<ConversationSidePanelProvider>
<ConversationSidePanelProvider>
<FilePreviewProvider owner={owner}>
<ConversationInnerLayout
activeConversationId={activeConversationId}
conversation={conversation}
Expand All @@ -105,8 +105,8 @@ const ConversationLayoutContent = ({
>
{children}
</ConversationInnerLayout>
</ConversationSidePanelProvider>
</FilePreviewProvider>
</FilePreviewProvider>
</ConversationSidePanelProvider>
{shouldDisplayWelcomeTourGuide && (
<WelcomeTourGuide
owner={owner}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ describe("ConversationSidePanelProvider selection", () => {
);
expect(probe.panel.data).toBe("msg_1@act_1");
});

it("addresses path-less files by id without colliding with paths", () => {
const probe = renderProvider();

act(() => probe.panel.openPanel({ type: "file_preview", fileId: "fil_1" }));
expect(probe.panel.data).toBe("id:fil_1");

act(() =>
probe.panel.openPanel({ type: "file_preview", filePath: "a.md" })
);
expect(probe.panel.data).toBe("a.md");
});
});

describe("ConversationSidePanelProvider toggle", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ type OpenPanelParams =
| {
type: "file_preview";
filePath: string;
fileId?: undefined;
}
| {
type: "file_preview";
fileId: string;
filePath?: undefined;
}
| {
type: "files";
Expand Down Expand Up @@ -91,6 +97,26 @@ export function useConversationSidePanelContext() {
return context;
}

const FILE_PREVIEW_FILE_ID_PREFIX = "id:";

export function encodeFilePreviewFileId(fileId: string): string {
return `${FILE_PREVIEW_FILE_ID_PREFIX}${fileId}`;
}

// Content fragments without a sandbox path are addressed by id instead.
export function parseFilePreviewData(data?: string): {
fileId?: string;
filePath?: string;
} {
if (!data) {
return {};
}

return data.startsWith(FILE_PREVIEW_FILE_ID_PREFIX)
? { fileId: data.slice(FILE_PREVIEW_FILE_ID_PREFIX.length) }
: { filePath: data };
}

export function parseDataAsMessageIdAndActionId(data?: string): {
messageId?: string;
actionId?: string;
Expand Down Expand Up @@ -118,7 +144,7 @@ function getPanelData(params: OpenPanelParams): string {
: params.fileId;

case FILE_PREVIEW_SIDE_PANEL_TYPE:
return params.filePath;
return params.filePath ?? encodeFilePreviewFileId(params.fileId);

case FILES_SIDE_PANEL_TYPE:
return "files";
Expand Down
128 changes: 64 additions & 64 deletions front/components/assistant/conversation/FilePreviewContext.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import { FilePreviewDialog } from "@app/components/file_explorer/FilePreviewDialog";
import type { FileEntry } from "@app/components/file_explorer/types";
import { ConversationSidePanelContext } from "@app/components/assistant/conversation/ConversationSidePanelContext";
import { isFilePreviewableContentType } from "@app/components/file_explorer/utils";
import { useSendNotification } from "@app/hooks/useNotification";
import {
fetchFileIdFromPath,
getFileDownloadUrl,
getFilePathDownloadUrl,
getFilePathViewUrl,
getFileViewUrl,
} from "@app/lib/swr/files";
import { normalizeError } from "@app/types/shared/utils/error_utils";
import type { LightWorkspaceType } from "@app/types/user";
import type { ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
useRef,
} from "react";

interface PreviewableFile {
Expand All @@ -25,14 +25,21 @@ interface PreviewableFile {
contentType: string;
}

interface FrameFile {
fileId?: string | null;
filePath?: string;
}

type FilePreviewContextType = {
canPreview: boolean;
openFilePreview: (file: PreviewableFile) => void;
resolveFileIdFromPath: (filePath: string) => Promise<string | null>;
openFramePreview: (frame: FrameFile) => Promise<void>;
};

const FilePreviewContext = createContext<FilePreviewContextType>({
canPreview: false,
openFilePreview: () => {},
resolveFileIdFromPath: () => Promise.resolve(null),
openFramePreview: () => Promise.resolve(),
});

interface FilePreviewProviderProps {
Expand All @@ -44,87 +51,80 @@ export function FilePreviewProvider({
owner,
children,
}: FilePreviewProviderProps) {
const [previewState, setPreviewState] = useState<{
entry: FileEntry;
fileUrl: string;
downloadUrl: string;
} | null>(null);
const sidePanel = useContext(ConversationSidePanelContext);
const sendNotification = useSendNotification();
const canPreview = sidePanel != null;

// The side panel context value changes on every panel navigation. Reading it
// through a ref keeps the callbacks below stable, so citations do not all
// re-render each time the panel switches.
const sidePanelRef = useRef(sidePanel);
useEffect(() => {
sidePanelRef.current = sidePanel;
});

const openFilePreview = useCallback(
(file: PreviewableFile) => {
const fileUrl = file.filePath
? getFilePathViewUrl(owner, file.filePath)
: file.fileId
? getFileViewUrl(owner, file.fileId)
: null;
const panel = sidePanelRef.current;

if (isFilePreviewableContentType(file.contentType) && panel) {
if (file.filePath) {
panel.openPanel({ type: "file_preview", filePath: file.filePath });
return;
}
if (file.fileId) {
panel.openPanel({ type: "file_preview", fileId: file.fileId });
return;
}
}

const downloadUrl = file.filePath
? getFilePathDownloadUrl(owner, file.filePath)
: file.fileId
? getFileDownloadUrl(owner, file.fileId)
: null;

if (!fileUrl || !downloadUrl) {
return;
}

if (!isFilePreviewableContentType(file.contentType)) {
if (downloadUrl) {
window.open(downloadUrl, "_blank");
return;
}

setPreviewState({
entry: {
kind: "file",
isDirectory: false,
fileName: file.title,
path: file.filePath ?? file.title,
contentType: file.contentType,
fileId: file.fileId ?? null,
thumbnailUrl: null,
sizeBytes: 0,
// No mtime here: stands in to cache-bust the per-URL cached PDF
// conversion, so an edited file stops rendering its stale one.
lastModifiedMs: Date.now(),
},
fileUrl,
downloadUrl,
});
},
[owner]
);

const resolveFileIdFromPath = useCallback(
(filePath: string) => fetchFileIdFromPath({ owner, filePath }),
[owner]
);
const openFramePreview = useCallback(
async ({ fileId, filePath }: FrameFile) => {
try {
const resolvedFileId =
fileId ??
(filePath ? await fetchFileIdFromPath({ owner, filePath }) : null);

const handleDownload = useCallback(async () => {
if (previewState?.downloadUrl) {
window.open(previewState.downloadUrl, "_blank");
}
}, [previewState?.downloadUrl]);
if (!resolvedFileId) {
throw new Error("No linked file was found for this Frame.");
}

sidePanelRef.current?.openPanel({
type: "interactive_content",
fileId: resolvedFileId,
});
} catch (error) {
sendNotification({
type: "error",
title: "Failed to open Frame",
description: normalizeError(error).message,
});
}
},
[owner, sendNotification]
);

const contextValue = useMemo(
() => ({ openFilePreview, resolveFileIdFromPath }),
[openFilePreview, resolveFileIdFromPath]
() => ({ canPreview, openFilePreview, openFramePreview }),
[canPreview, openFilePreview, openFramePreview]
);

return (
<FilePreviewContext.Provider value={contextValue}>
{children}
<FilePreviewDialog
entry={previewState?.entry ?? null}
fileUrl={previewState?.fileUrl ?? null}
isOpen={!!previewState}
onOpenChange={(open) => {
if (!open) {
setPreviewState(null);
}
}}
onDownload={handleDownload}
owner={owner}
/>
</FilePreviewContext.Provider>
);
}
Expand Down
Loading
Loading