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
3 changes: 2 additions & 1 deletion apps/server/src/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ describe("assetResponseHeaders", () => {
});
expect(assetResponseHeaders({ mimeType: "text/html" })).toMatchObject({
"Content-Type": "text/html; charset=utf-8",
"Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-modals",
"Content-Security-Policy":
"sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads",
});
});

Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,9 @@ const isSafeInlineVideoMimeType = (mimeType: string): boolean =>
DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && mimeType.toLowerCase().startsWith("video/");
// An inline HTML attachment runs in its own opaque origin, like the viewer's
// sandboxed frame: scripts and forms work, the app's session does not.
// Downloads stay allowed so download links and buttons in the page work.
const INLINE_HTML_CONTENT_SECURITY_POLICY =
"sandbox allow-scripts allow-forms allow-popups allow-modals";
"sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads";

/** RFC 6266 disposition with an ASCII fallback name plus a UTF-8 `filename*`. */
export function downloadContentDisposition(fileName?: string): string {
Expand Down
22 changes: 11 additions & 11 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import {
serializeTableElementToCsv,
serializeTableElementToMarkdown,
} from "../markdown-clipboard";
import { rehypeHeadingIds, SANITIZED_FRAGMENT_PREFIX } from "../markdown-heading-ids";
import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation";
import { remarkKeepWindowsPathDestinations } from "../markdown-windows-paths";
import { normalizeOrderedListContinuations } from "../markdown-source-normalize";
Expand All @@ -82,6 +83,7 @@ import {
type MarkdownFileLinkMeta,
} from "../markdown-links";
import { readLocalApi } from "../localApi";
import { faviconUrlForOrigin } from "../lib/favicon";
import { cn } from "../lib/utils";
import { useRightPanelStore } from "../rightPanelStore";
import { useActiveEnvironmentId } from "../state/entities";
Expand Down Expand Up @@ -203,6 +205,8 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
const CHAT_MARKDOWN_REHYPE_PLUGINS = [
rehypeRaw,
[rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA],
// Avi Code addition: heading ids go on after sanitizing, which would prefix them a second time.
rehypeHeadingIds,
] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>;

function extractFenceLanguage(className: string | undefined): string {
Expand Down Expand Up @@ -1008,13 +1012,15 @@ const failedFaviconHosts = new Set<string>();

const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) {
const [failedHost, setFailedHost] = useState<string | null>(null);
// Avi Code addition: private and internal hosts never reach the favicon provider.
const faviconUrl = faviconUrlForOrigin(`https://${host}`);
return (
<span className="chat-markdown-link-favicon" aria-hidden>
{failedHost === host || failedFaviconHosts.has(host) ? (
{faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? (
<GlobeIcon className={MARKDOWN_LINK_FAVICON_CLASS_NAME} />
) : (
<img
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=32`}
src={faviconUrl}
alt=""
loading="lazy"
draggable={false}
Expand Down Expand Up @@ -1064,8 +1070,6 @@ function plainHastText(node: unknown): string | null {
return parts.every((part) => part !== null) ? parts.join("") : null;
}

const SANITIZED_FRAGMENT_PREFIX = "user-content-";

function decodeMarkdownFragmentId(href: string): string {
const encodedId = href.slice(1);
try {
Expand Down Expand Up @@ -1114,14 +1118,10 @@ function handleMarkdownFragmentClick(event: ReactMouseEvent<HTMLAnchorElement>,
return;
}

const target = findMarkdownFragmentTarget(event.currentTarget, href);
if (!target) return;

// Never let the browser follow the fragment or write it to the URL: desktop keeps
// its route in the hash, so replacing the hash navigates away from the thread.
event.preventDefault();
const nextUrl = new URL(window.location.href);
nextUrl.hash = href.slice(1);
window.history.pushState(window.history.state, "", nextUrl);
target.scrollIntoView({ block: "nearest" });
findMarkdownFragmentTarget(event.currentTarget, href)?.scrollIntoView({ block: "start" });
}

function MarkdownExternalLinkContent({
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/files/BrowserDocumentFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const isPdfPreviewFile = (path: string): boolean =>
/**
* An HTML page or PDF rendered in place. HTML runs in a sandboxed frame with
* an opaque origin, so a page cannot reach the app's session or storage.
* Downloads stay allowed so download links and buttons in the page work.
*/
export function BrowserDocumentFrame(props: {
readonly src: string;
Expand All @@ -21,7 +22,7 @@ export function BrowserDocumentFrame(props: {
src={props.src}
title={props.title}
className={className}
sandbox="allow-scripts allow-forms allow-popups allow-modals"
sandbox="allow-scripts allow-forms allow-popups allow-modals allow-downloads"
/>
);
}
44 changes: 43 additions & 1 deletion apps/web/src/components/files/FilePreviewPanel.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import type { ScopedThreadRef } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import {
formatFileCommentRange,
normalizeFileCommentRange,
remapFileCommentAnnotations,
} from "./fileCommentAnnotations";
import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode";
import {
isMarkdownPreviewFile,
setMarkdownTaskChecked,
workspaceAssetResource,
} from "./filePreviewMode";

describe("file comment annotations", () => {
it("normalizes and formats selected line ranges", () => {
Expand Down Expand Up @@ -80,3 +85,40 @@ describe("setMarkdownTaskChecked", () => {
expect(setMarkdownTaskChecked(markdown, 200, true)).toBe(markdown);
});
});

describe("workspaceAssetResource", () => {
const input = {
threadRef: {
environmentId: "env" as ScopedThreadRef["environmentId"],
threadId: "thread-1" as ScopedThreadRef["threadId"],
},
workspaceRoot: "/repo",
absolutePath: "/repo/docs/index.html",
};

it("resolves through the thread once the server knows it", () => {
expect(workspaceAssetResource({ ...input, draft: false, externalRoot: false })).toEqual({
_tag: "workspace-file",
threadId: "thread-1",
path: "/repo/docs/index.html",
});
});

it("names the workspace root for a draft, which the server cannot resolve from a thread", () => {
expect(workspaceAssetResource({ ...input, draft: true, externalRoot: false })).toEqual({
_tag: "workspace-file",
threadId: "thread-1",
path: "/repo/docs/index.html",
workspaceRoot: "/repo",
});
});

it("names the workspace root for a surface showing another repository", () => {
expect(workspaceAssetResource({ ...input, draft: false, externalRoot: true })).toEqual({
_tag: "workspace-file",
threadId: "thread-1",
path: "/repo/docs/index.html",
workspaceRoot: "/repo",
});
});
});
70 changes: 46 additions & 24 deletions apps/web/src/components/files/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,11 @@ import { installFileEditorDismissal } from "./fileEditorDismissal";
import { LocalCommentAnnotation } from "./LocalCommentAnnotation";
import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision";
import { fileBreadcrumbs } from "./filePath";
import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode";
import {
isMarkdownPreviewFile,
setMarkdownTaskChecked,
workspaceAssetResource,
} from "./filePreviewMode";
import { FileSaveCoordinator } from "./fileSaveCoordinator";
import { BrowserDocumentFrame, isPdfPreviewFile } from "./BrowserDocumentFrame";
import { isProjectFileMissing } from "./projectFileErrorMessage";
Expand Down Expand Up @@ -146,12 +150,16 @@ function WorkspaceImagePreview(props: {
readonly absolutePath: string;
readonly alt: string;
/**
* Avi Code addition: the repository this file belongs to, when the surface is
* showing another project's root. Without it the server resolved the image
* against the thread's own workspace and rejected it as outside, so a
* cross-repo image failed to load while its text neighbours opened fine.
* Avi Code addition: the root the surface shows. Sent to the server when the
* surface shows another project's root, or the thread is still a draft;
* otherwise the server resolved the image against the thread's own
* workspace (or found no thread at all) and the image failed to load while
* its text neighbours opened fine.
*/
readonly workspaceRoot: string | null;
readonly workspaceRoot: string;
readonly externalRoot: boolean;
/** The thread is a draft the server does not know yet. */
readonly draft: boolean;
/**
* Avi Code addition: reload triggers so an image an agent is about to write
* loads itself once it lands, matching the text preview's behaviour.
Expand All @@ -160,13 +168,15 @@ function WorkspaceImagePreview(props: {
readonly reloadSignal: string;
}) {
const resource = useMemo<AssetResource>(
() => ({
_tag: "workspace-file",
threadId: props.threadRef.threadId,
path: props.absolutePath,
...(props.workspaceRoot === null ? {} : { workspaceRoot: props.workspaceRoot }),
}),
[props.threadRef.threadId, props.absolutePath, props.workspaceRoot],
() =>
workspaceAssetResource({
threadRef: props.threadRef,
draft: props.draft,
externalRoot: props.externalRoot,
workspaceRoot: props.workspaceRoot,
absolutePath: props.absolutePath,
}),
[props.threadRef, props.draft, props.externalRoot, props.workspaceRoot, props.absolutePath],
);
const assetUrl = useAssetUrlState(props.environmentId, resource);
const refreshAsset = useAtomRefresh(
Expand Down Expand Up @@ -201,6 +211,7 @@ function WorkspaceImagePreview(props: {
}

return assetUrl._tag === "Success" ? (
// oxlint-disable-next-line t3code/require-centered-scroll-gutter -- The image is capped at max-h-full max-w-full, so this never scrolls.
<div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4">
<img
key={`${assetUrl.url}:${reloadNonce}`}
Expand All @@ -226,8 +237,11 @@ function WorkspaceBrowserPreview(props: {
readonly environmentId: EnvironmentId;
readonly threadRef: ScopedThreadRef;
readonly absolutePath: string;
/** Avi Code addition: set when the surface shows another repo's root. */
readonly workspaceRoot: string | null;
/** Avi Code addition: see {@link WorkspaceImagePreview}. */
readonly workspaceRoot: string;
readonly externalRoot: boolean;
/** The thread is a draft the server does not know yet. */
readonly draft: boolean;
readonly title: string;
/**
* Avi Code addition: changes when the thread checkpoints, so a page the agent
Expand All @@ -236,13 +250,15 @@ function WorkspaceBrowserPreview(props: {
readonly reloadSignal: string;
}) {
const resource = useMemo<AssetResource>(
() => ({
_tag: "workspace-file",
threadId: props.threadRef.threadId,
path: props.absolutePath,
...(props.workspaceRoot === null ? {} : { workspaceRoot: props.workspaceRoot }),
}),
[props.threadRef.threadId, props.absolutePath, props.workspaceRoot],
() =>
workspaceAssetResource({
threadRef: props.threadRef,
draft: props.draft,
externalRoot: props.externalRoot,
workspaceRoot: props.workspaceRoot,
absolutePath: props.absolutePath,
}),
[props.threadRef, props.draft, props.externalRoot, props.workspaceRoot, props.absolutePath],
);
const assetUrl = useAssetUrlState(props.environmentId, resource);

Expand Down Expand Up @@ -797,6 +813,8 @@ export default function FilePreviewPanel({
onOpenFile,
onPendingChange,
}: FilePreviewPanelProps) {
// A draft's composer target is its draft id; a thread the server knows is a ref.
const draft = typeof composerDraftTarget === "string";
const { resolvedTheme } = useTheme();
const wordWrap = useClientSettings((settings) => settings.wordWrap);
const primaryEnvironmentId = usePrimaryEnvironmentId();
Expand Down Expand Up @@ -1065,7 +1083,9 @@ export default function FilePreviewPanel({
threadRef={threadRef}
absolutePath={absolutePath}
alt={relativePath}
workspaceRoot={isExternalRoot ? cwd : null}
workspaceRoot={cwd}
externalRoot={isExternalRoot}
draft={draft}
isThreadWorking={isThreadWorking}
reloadSignal={reloadSignal}
/>
Expand All @@ -1075,7 +1095,9 @@ export default function FilePreviewPanel({
environmentId={environmentId}
threadRef={threadRef}
absolutePath={absolutePath}
workspaceRoot={isExternalRoot ? cwd : null}
workspaceRoot={cwd}
externalRoot={isExternalRoot}
draft={draft}
title={relativePath}
reloadSignal={reloadSignal}
/>
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/components/files/filePreviewMode.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { AssetResource, ScopedThreadRef } from "@t3tools/contracts";

export const isMarkdownPreviewFile = (path: string): boolean => /\.(?:md|mdx)$/i.test(path);

export function setMarkdownTaskChecked(
Expand All @@ -16,3 +18,29 @@ export function setMarkdownTaskChecked(

return `${markdown.slice(0, markerOffset + 1)}${checked ? "x" : " "}${markdown.slice(markerOffset + 2)}`;
}

/**
* The signed-asset resource for a workspace file the panel renders in place
* (image, HTML, PDF).
*
* The server resolves a plain workspace file through its thread, which a draft
* does not have yet, so a draft names its workspace root explicitly. A surface
* showing another repository does the same. The server honours an explicit root
* only when it names a registered project.
*/
export function workspaceAssetResource(input: {
readonly threadRef: ScopedThreadRef;
/** The thread is a draft the server does not know yet. */
readonly draft: boolean;
/** The surface shows another repository's root rather than the thread's. */
readonly externalRoot: boolean;
readonly workspaceRoot: string;
readonly absolutePath: string;
}): Extract<AssetResource, { readonly _tag: "workspace-file" }> {
return {
_tag: "workspace-file",
threadId: input.threadRef.threadId,
path: input.absolutePath,
...(input.draft || input.externalRoot ? { workspaceRoot: input.workspaceRoot } : {}),
};
}
2 changes: 1 addition & 1 deletion apps/web/src/components/preview/PreviewEmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export function PreviewEmptyState({
{startButton}
</Empty>
) : (
<div className="flex h-full min-h-0 overflow-y-auto px-5 py-8">
<div className="scrollbar-gutter-both flex h-full min-h-0 overflow-y-auto px-5 py-8">
<div className="m-auto flex w-full max-w-xl flex-col gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<RadioTower className="size-4 shrink-0" />
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/preview/PreviewUnreachable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function PreviewUnreachable({ url, code, description, onReload, onClearCo
const showClearCookies = cookieBloat && onClearCookies !== undefined;

return (
<div className="relative flex h-full min-h-0 w-full overflow-y-auto bg-background">
<div className="scrollbar-gutter-both relative flex h-full min-h-0 w-full overflow-y-auto bg-background">
<div className="mx-auto flex w-full max-w-xl flex-1 flex-col px-8 py-12 sm:py-16">
<ErrorIcon className="mb-6 size-12 text-muted-foreground/70" />
<h1 className="mb-3 text-2xl font-semibold leading-tight text-foreground">
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/usage/UsagePageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ export function UsagePageContent() {
}, [providers]);

return (
<div className="flex-1 overflow-y-auto">
<div className="scrollbar-gutter-both flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl px-5 py-6">
{/* Quota Gauges */}
{providersWithQuota.length > 0 ? (
Expand Down
Loading
Loading