fix(webui): validate inferred file paths before preview (#4935)
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
|
||||
export type FilePreviewAvailabilityResolver = (path: string) => Promise<boolean>;
|
||||
|
||||
const FilePreviewAvailabilityContext = createContext<
|
||||
FilePreviewAvailabilityResolver | undefined
|
||||
>(undefined);
|
||||
|
||||
export function FilePreviewAvailabilityProvider({
|
||||
children,
|
||||
resolve,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
resolve?: FilePreviewAvailabilityResolver;
|
||||
}) {
|
||||
return (
|
||||
<FilePreviewAvailabilityContext.Provider value={resolve}>
|
||||
{children}
|
||||
</FilePreviewAvailabilityContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useFilePreviewAvailabilityResolver() {
|
||||
return useContext(FilePreviewAvailabilityContext);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
Children,
|
||||
isValidElement,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { Components, Options as ReactMarkdownOptions } from "react-markdown";
|
||||
@@ -14,6 +16,10 @@ import remarkMath from "remark-math";
|
||||
|
||||
import { AttachmentTile } from "@/components/AttachmentTile";
|
||||
import { CodeBlock } from "@/components/CodeBlock";
|
||||
import {
|
||||
useFilePreviewAvailabilityResolver,
|
||||
type FilePreviewAvailabilityResolver,
|
||||
} from "@/components/FilePreviewAvailabilityContext";
|
||||
import {
|
||||
FileReferenceChip,
|
||||
isFilePatternReference,
|
||||
@@ -50,6 +56,50 @@ type InlineLinkPreview = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
type AvailabilityResult = {
|
||||
available: boolean;
|
||||
path: string;
|
||||
resolve: FilePreviewAvailabilityResolver;
|
||||
};
|
||||
|
||||
function InferredFileReferenceChip({
|
||||
path,
|
||||
onOpen,
|
||||
}: {
|
||||
path: string;
|
||||
onOpen?: (path: string) => void;
|
||||
}) {
|
||||
const resolve = useFilePreviewAvailabilityResolver();
|
||||
const [result, setResult] = useState<AvailabilityResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolve || !onOpen) return;
|
||||
let cancelled = false;
|
||||
resolve(path)
|
||||
.then((available) => {
|
||||
if (!cancelled) setResult({ available, path, resolve });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setResult({ available: false, path, resolve });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [onOpen, path, resolve]);
|
||||
|
||||
const resolvedAvailable = !resolve || (
|
||||
result?.resolve === resolve
|
||||
&& result.path === path
|
||||
&& result.available
|
||||
);
|
||||
return (
|
||||
<FileReferenceChip
|
||||
path={path}
|
||||
onOpen={onOpen && resolvedAvailable ? onOpen : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const SAFE_INLINE_HTML_TAGS = new Set(["mark", "sub", "sup"]);
|
||||
|
||||
function extensionOf(value: string): string {
|
||||
@@ -402,7 +452,12 @@ export default function MarkdownTextRenderer({
|
||||
}
|
||||
const raw = String(kids).replace(/\n$/, "");
|
||||
if (isLikelyFilePath(raw)) {
|
||||
return <FileReferenceChip path={raw} onOpen={onOpenFilePreview} />;
|
||||
return (
|
||||
<InferredFileReferenceChip
|
||||
path={raw}
|
||||
onOpen={onOpenFilePreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
/** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */
|
||||
const widePlainBlock = raw.includes("\n") || raw.length > 120;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
|
||||
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
|
||||
import { PromptNavigator } from "@/components/thread/PromptNavigator";
|
||||
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
|
||||
@@ -12,6 +13,8 @@ import { ThreadViewport, type ThreadViewportHandle } from "@/components/thread/T
|
||||
import { useNanobotStream, type SendAttachment, type SendOptions } from "@/hooks/useNanobotStream";
|
||||
import { useSessionHistory } from "@/hooks/useSessions";
|
||||
import {
|
||||
ApiError,
|
||||
fetchFilePreviewAvailability,
|
||||
fetchInstalledCliApps,
|
||||
fetchMcpPresets,
|
||||
fetchSettings,
|
||||
@@ -109,6 +112,12 @@ const FILE_PREVIEW_MAX_WIDTH = 860;
|
||||
const FILE_PREVIEW_MIN_MAIN_WIDTH = 420;
|
||||
const FILE_PREVIEW_CLOSE_ANIMATION_MS = 320;
|
||||
|
||||
type FilePreviewAvailabilityCacheEntry = {
|
||||
available?: boolean;
|
||||
promise: Promise<boolean>;
|
||||
revision: number;
|
||||
};
|
||||
|
||||
function clampFilePreviewWidth(width: number, maxWidth: number): number {
|
||||
return Math.min(Math.max(width, FILE_PREVIEW_MIN_WIDTH), maxWidth);
|
||||
}
|
||||
@@ -397,6 +406,48 @@ export function ThreadShell({
|
||||
}, []);
|
||||
|
||||
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
|
||||
const filePreviewAvailabilityCache = useMemo(
|
||||
() => new Map<string, FilePreviewAvailabilityCacheEntry>(),
|
||||
[historyKey, token],
|
||||
);
|
||||
const filePreviewAvailabilityRevision = displayMessages.length;
|
||||
const resolveFilePreviewAvailability = useCallback((path: string) => {
|
||||
if (!historyKey) return Promise.resolve(false);
|
||||
const cached = filePreviewAvailabilityCache.get(path);
|
||||
if (
|
||||
cached
|
||||
&& (cached.available !== false || cached.revision === filePreviewAvailabilityRevision)
|
||||
) {
|
||||
return cached.promise;
|
||||
}
|
||||
const pending = fetchFilePreviewAvailability(token, historyKey, path).catch(
|
||||
(error: unknown) => {
|
||||
if (error instanceof ApiError) {
|
||||
if (error.status === 404 && /API route not found/i.test(error.message)) {
|
||||
return true;
|
||||
}
|
||||
if ([400, 403, 404, 415].includes(error.status)) return false;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
);
|
||||
const entry: FilePreviewAvailabilityCacheEntry = {
|
||||
promise: pending,
|
||||
revision: filePreviewAvailabilityRevision,
|
||||
};
|
||||
filePreviewAvailabilityCache.set(path, entry);
|
||||
void pending.then((available) => {
|
||||
if (filePreviewAvailabilityCache.get(path) === entry) {
|
||||
entry.available = available;
|
||||
}
|
||||
});
|
||||
return pending;
|
||||
}, [
|
||||
filePreviewAvailabilityCache,
|
||||
filePreviewAvailabilityRevision,
|
||||
historyKey,
|
||||
token,
|
||||
]);
|
||||
|
||||
const showHeroComposer = messages.length === 0 && !loading;
|
||||
const wasShowingHeroComposerRef = useRef(showHeroComposer);
|
||||
@@ -830,27 +881,31 @@ export function ThreadShell({
|
||||
sessionInfoAction={sessionInfoAction}
|
||||
/>
|
||||
) : null}
|
||||
<ThreadViewport
|
||||
ref={viewportRef}
|
||||
messages={displayMessages}
|
||||
isStreaming={isStreaming}
|
||||
emptyState={emptyState}
|
||||
composer={composer}
|
||||
scrollToBottomSignal={scrollToBottomSignal}
|
||||
scrollToLatestUserPromptSignal={scrollToLatestUserPromptSignal}
|
||||
conversationKey={historyKey}
|
||||
showScrollToBottomButton={!!session}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
slashCommands={slashCommands}
|
||||
forkBoundaryMessageCount={forkBoundaryMessageCount}
|
||||
hasMoreBefore={hasMoreBefore}
|
||||
loadingOlder={loadingOlder}
|
||||
userMessageOffset={userMessageOffset}
|
||||
onLoadOlder={loadOlder}
|
||||
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
|
||||
onForkFromMessage={onForkChat ? handleForkFromMessage : undefined}
|
||||
/>
|
||||
<FilePreviewAvailabilityProvider
|
||||
resolve={historyKey ? resolveFilePreviewAvailability : undefined}
|
||||
>
|
||||
<ThreadViewport
|
||||
ref={viewportRef}
|
||||
messages={displayMessages}
|
||||
isStreaming={isStreaming}
|
||||
emptyState={emptyState}
|
||||
composer={composer}
|
||||
scrollToBottomSignal={scrollToBottomSignal}
|
||||
scrollToLatestUserPromptSignal={scrollToLatestUserPromptSignal}
|
||||
conversationKey={historyKey}
|
||||
showScrollToBottomButton={!!session}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
slashCommands={slashCommands}
|
||||
forkBoundaryMessageCount={forkBoundaryMessageCount}
|
||||
hasMoreBefore={hasMoreBefore}
|
||||
loadingOlder={loadingOlder}
|
||||
userMessageOffset={userMessageOffset}
|
||||
onLoadOlder={loadOlder}
|
||||
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
|
||||
onForkFromMessage={onForkChat ? handleForkFromMessage : undefined}
|
||||
/>
|
||||
</FilePreviewAvailabilityProvider>
|
||||
</div>
|
||||
{filePreviewPath && historyKey ? (
|
||||
<FilePreviewPanel
|
||||
|
||||
@@ -200,6 +200,24 @@ export async function fetchFilePreview(
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchFilePreviewAvailability(
|
||||
token: string,
|
||||
key: string,
|
||||
path: string,
|
||||
base: string = "",
|
||||
): Promise<boolean> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("path", path);
|
||||
query.set("probe", "1");
|
||||
const payload = await request<{ available?: boolean }>(
|
||||
`${base}/api/sessions/${encodeURIComponent(key)}/file-preview?${query}`,
|
||||
token,
|
||||
undefined,
|
||||
API_READ_TIMEOUT_MS,
|
||||
);
|
||||
return payload.available !== false;
|
||||
}
|
||||
|
||||
export async function fetchSessionAutomations(
|
||||
token: string,
|
||||
key: string,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createModelConfiguration,
|
||||
deleteSession,
|
||||
fetchFilePreview,
|
||||
fetchFilePreviewAvailability,
|
||||
fetchAutomations,
|
||||
fetchApiService,
|
||||
fetchCliApps,
|
||||
@@ -102,6 +103,32 @@ describe("webui API helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("probes file preview availability without requesting contents", async () => {
|
||||
await expect(
|
||||
fetchFilePreviewAvailability("tok", "websocket:chat-1", "notes/ready.md"),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/sessions/websocket%3Achat-1/file-preview?path=notes%2Fready.md&probe=1",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
credentials: "same-origin",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when a file preview probe is unavailable", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ available: false }),
|
||||
} as Response);
|
||||
|
||||
await expect(
|
||||
fetchFilePreviewAvailability("tok", "websocket:chat-1", "notes/missing.md"),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("percent-encodes websocket keys when fetching session automations", async () => {
|
||||
await fetchSessionAutomations("tok", "websocket:chat-1");
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
|
||||
import MarkdownTextRenderer from "@/components/MarkdownTextRenderer";
|
||||
|
||||
describe("MarkdownTextRenderer", () => {
|
||||
@@ -34,6 +35,75 @@ describe("MarkdownTextRenderer", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps unavailable inferred inline file paths non-interactive", async () => {
|
||||
const onOpenFilePreview = vi.fn();
|
||||
const resolve = vi.fn().mockResolvedValue(false);
|
||||
render(
|
||||
<FilePreviewAvailabilityProvider resolve={resolve}>
|
||||
<MarkdownTextRenderer onOpenFilePreview={onOpenFilePreview}>
|
||||
{"Future file: `notes/missing.md`"}
|
||||
</MarkdownTextRenderer>
|
||||
</FilePreviewAvailabilityProvider>,
|
||||
);
|
||||
|
||||
const reference = screen.getByTestId("inline-file-path");
|
||||
expect(reference).toHaveTextContent("missing.md");
|
||||
await waitFor(() => expect(resolve).toHaveBeenCalledWith("notes/missing.md"));
|
||||
expect(reference).not.toHaveAttribute("role");
|
||||
expect(reference).not.toHaveAttribute("tabindex");
|
||||
|
||||
fireEvent.click(reference);
|
||||
|
||||
expect(onOpenFilePreview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps inferred inline file paths non-interactive when availability lookup fails", async () => {
|
||||
const onOpenFilePreview = vi.fn();
|
||||
let rejectAvailability!: (reason?: unknown) => void;
|
||||
const resolve = vi.fn(() => new Promise<boolean>((_resolve, reject) => {
|
||||
rejectAvailability = reject;
|
||||
}));
|
||||
render(
|
||||
<FilePreviewAvailabilityProvider resolve={resolve}>
|
||||
<MarkdownTextRenderer onOpenFilePreview={onOpenFilePreview}>
|
||||
{"Unreadable file: `notes/locked.md`"}
|
||||
</MarkdownTextRenderer>
|
||||
</FilePreviewAvailabilityProvider>,
|
||||
);
|
||||
|
||||
const reference = screen.getByTestId("inline-file-path");
|
||||
await waitFor(() => expect(resolve).toHaveBeenCalledWith("notes/locked.md"));
|
||||
await act(async () => {
|
||||
rejectAvailability(new Error("probe failed"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(reference).not.toHaveAttribute("role");
|
||||
expect(reference).not.toHaveAttribute("tabindex");
|
||||
fireEvent.click(reference);
|
||||
expect(onOpenFilePreview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("makes available inferred inline file paths previewable", async () => {
|
||||
const onOpenFilePreview = vi.fn();
|
||||
const resolve = vi.fn().mockResolvedValue(true);
|
||||
render(
|
||||
<FilePreviewAvailabilityProvider resolve={resolve}>
|
||||
<MarkdownTextRenderer onOpenFilePreview={onOpenFilePreview}>
|
||||
{"Existing file: `notes/ready.md`"}
|
||||
</MarkdownTextRenderer>
|
||||
</FilePreviewAvailabilityProvider>,
|
||||
);
|
||||
|
||||
const reference = screen.getByTestId("inline-file-path");
|
||||
await waitFor(() => expect(reference).toHaveAttribute("role", "button"));
|
||||
expect(reference).toHaveAttribute("tabindex", "0");
|
||||
|
||||
fireEvent.click(reference);
|
||||
|
||||
expect(onOpenFilePreview).toHaveBeenCalledWith("notes/ready.md");
|
||||
});
|
||||
|
||||
it("does not treat non-file hrefs as previews just because the label looks like a file", () => {
|
||||
const onOpenFilePreview = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -231,6 +231,59 @@ describe("ThreadShell", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps inferred file paths non-interactive when the availability probe fails", async () => {
|
||||
const client = makeClient();
|
||||
let resolveProbe!: (value: Response) => void;
|
||||
const probe = new Promise<Response>((resolve) => {
|
||||
resolveProbe = resolve;
|
||||
});
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("websocket%3Apreview-error/webui-thread")) {
|
||||
return Promise.resolve(httpJson(transcriptFromSimpleMessages([
|
||||
{ role: "assistant", content: "Unreadable file: `prompts/dream.md`" },
|
||||
])));
|
||||
}
|
||||
if (url.includes("websocket%3Apreview-error/file-preview?")) return probe;
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("preview-error")}
|
||||
title="Preview error"
|
||||
onToggleSidebar={() => {}}
|
||||
/>,
|
||||
));
|
||||
|
||||
const reference = await screen.findByTestId("inline-file-path");
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("file-preview?path=prompts%2Fdream.md&probe=1"),
|
||||
expect.anything(),
|
||||
));
|
||||
await act(async () => {
|
||||
resolveProbe({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => "failed to read file",
|
||||
json: async () => ({}),
|
||||
} as Response);
|
||||
await probe;
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(reference).not.toHaveAttribute("role");
|
||||
expect(reference).not.toHaveAttribute("tabindex");
|
||||
fireEvent.click(reference);
|
||||
expect(screen.queryByText("failed to read file")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not navigate away when clicking the chat title", async () => {
|
||||
const client = makeClient();
|
||||
const onGoHome = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user