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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ Upstream: t3code 0.0.31
- Settling a thread closes its idle terminals and keeps busy ones open (#13673 by Theo Browne)
- Cursor and Grok replies stay one message when a tool reports progress (#13386 by Julius Marminge)
- OpenCode starts with newer versions that print a different ready line (#13651 by Shirish Pothi)
- The Files panel collapse-all button tracks folders you open by hand (#8889 by Utkarsh Patil)
- Show a file tree beside the diff panel to jump to any changed file (#9330 by Julius Marminge)
- Zoom and pan expanded images with the scroll wheel, a click, or the keyboard (#10869 by maria)
- The file viewer shows HTML pages and PDFs rendered in place (#9143 by Julius Marminge)

## 0.0.31-avicode.9.16 (2026-09-24)

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/electron/ElectronProtocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ describe("ElectronProtocol", () => {
"http:",
"https:",
]);
assert.deepEqual(directives["frame-src"], ["'self'", "blob:", "http:", "https:"]);
assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]);
});
});
4 changes: 3 additions & 1 deletion apps/desktop/src/electron/ElectronProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat
"style-src 'self' 'unsafe-inline'",
`font-src 'self' ${input.scheme}: data:`,
"worker-src 'self' blob:",
"frame-src 'self' https://challenges.cloudflare.com",
// The file viewer frames HTML and PDF files from signed asset URLs on runtime
// environments. HTML frames keep their own sandbox; script-src is unchanged.
"frame-src 'self' blob: http: https:",
"form-action 'self'",
].join("; ");
}
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,7 @@ const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = [
// popup, so without this every keystroke typed into it would be stolen by the
// type-to-focus rule and land in the composer.
'[data-thread-find-bar="true"]',
'[role="dialog"][aria-modal="true"]',
'[data-slot="dialog"]',
'[data-slot="menu-popup"]:is([data-open],[data-ending-style])',
'[data-slot="select-popup"]:is([data-open],[data-ending-style])',
Expand Down
196 changes: 133 additions & 63 deletions apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,21 @@ import {
ChevronsDownUpIcon,
ChevronsUpDownIcon,
Columns2Icon,
FolderTreeIcon,
PilcrowIcon,
Rows3Icon,
SearchIcon,
TextWrapIcon,
} from "lucide-react";
import * as Schema from "effect/Schema";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useOpenInPreferredEditor } from "../editorPreferences";
import { type DraftId } from "../composerDraftStore";
import { openDiffFilePrimaryAction } from "../diffFileActions";
import { useCheckpointDiff } from "~/lib/checkpointDiffState";
import { cn } from "~/lib/utils";
import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore";
import { useLocalStorage } from "../hooks/useLocalStorage";
import { useTheme } from "../hooks/useTheme";
import {
buildFileDiffRenderKey,
Expand All @@ -44,6 +47,8 @@ import { formatShortTimestamp } from "../timestampFormat";
import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
import { DiffStatLabel } from "./chat/DiffStatLabel";
import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView";
import { DiffFileTree } from "./diffs/DiffFileTree";
import { diffFileTreeEntries } from "./diffs/diffFileTree.logic";
import { Button } from "./ui/button";
import { ToggleGroup, Toggle } from "./ui/toggle-group";
import { Switch } from "./ui/switch";
Expand Down Expand Up @@ -75,6 +80,7 @@ import { buildBaseRefChoices, filterBaseRefChoices } from "../lib/baseRefChoices
type DiffRenderMode = "stacked" | "split";
type DiffThemeType = "light" | "dark";
const AUTOMATIC_BASE_REF = "__automatic_base_ref__";
const DIFF_FILE_TREE_STORAGE_KEY = "t3code.diffFileTreeOpen";

interface CollapsedDiffFilesState {
readonly scopeKey: string | null;
Expand Down Expand Up @@ -200,6 +206,11 @@ export default function DiffPanel({
const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
const [wordWrap, setWordWrap] = useState(settings.wordWrap);
const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace);
const [fileTreeOpen, setFileTreeOpen] = useLocalStorage(
DIFF_FILE_TREE_STORAGE_KEY,
false,
Schema.Boolean,
);
const [baseRefQuery, setBaseRefQuery] = useState("");
const [collapsedDiffFiles, setCollapsedDiffFiles] = useState<CollapsedDiffFilesState>(() => ({
scopeKey: null,
Expand Down Expand Up @@ -455,6 +466,7 @@ export default function DiffPanel({
const diffFileKeys = useMemo(() => codeViewFiles.map((file) => file.fileKey), [codeViewFiles]);
const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys);
const diffLineStat = useMemo(() => getDiffLineStat(renderableFiles), [renderableFiles]);
const fileTreeEntries = useMemo(() => diffFileTreeEntries(renderableFiles), [renderableFiles]);

useEffect(() => {
if (!selectedFilePath) return;
Expand All @@ -463,6 +475,29 @@ export default function DiffPanel({
codeViewRef.current?.scrollTo({ type: "item", id: file.fileKey, align: "start" });
}, [codeViewFiles, selectedFilePath, selectedFileRevealRequestId]);

// Held as state so the scroll runs after a collapsed file has been drawn open again; scrolling
// in the same tick would land on the folded header's position.
const [treeReveal, setTreeReveal] = useState<{ fileKey: string; id: number } | null>(null);
useEffect(() => {
if (treeReveal === null) return;
codeViewRef.current?.scrollTo({ type: "item", id: treeReveal.fileKey, align: "start" });
}, [treeReveal]);
const revealDiffFile = useCallback(
(filePath: string) => {
const file = codeViewFiles.find((candidate) => candidate.filePath === filePath);
if (!file) return;
if (file.collapsed) {
setCollapsedDiffFiles((current) => {
const next = new Set(current.scopeKey === collapseScopeKey ? current.fileKeys : []);
next.delete(file.fileKey);
return { scopeKey: collapseScopeKey, fileKeys: next };
});
}
setTreeReveal((current) => ({ fileKey: file.fileKey, id: (current?.id ?? 0) + 1 }));
},
[codeViewFiles, collapseScopeKey],
);

const openDiffFile = useCallback(
(filePath: string) => {
openDiffFilePrimaryAction({
Expand Down Expand Up @@ -809,6 +844,26 @@ export default function DiffPanel({
{diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"}
</TooltipPopup>
</Tooltip>
{codeViewFiles.length > 0 && (
<Tooltip>
<TooltipTrigger
render={
<Toggle
aria-label={fileTreeOpen ? "Hide file tree" : "Show file tree"}
variant="outline"
size="xs"
pressed={fileTreeOpen}
onPressedChange={(pressed) => setFileTreeOpen(Boolean(pressed))}
/>
}
>
<FolderTreeIcon className="size-3" />
</TooltipTrigger>
<TooltipPopup side="top">
{fileTreeOpen ? "Hide file tree" : "Show file tree"}
</TooltipPopup>
</Tooltip>
)}
</div>
</>
);
Expand Down Expand Up @@ -862,71 +917,86 @@ export default function DiffPanel({
</div>
)
) : renderablePatch.kind === "files" ? (
<div
className="min-h-0 flex-1"
onClickCapture={(event) => {
const composedPath = event.nativeEvent.composedPath?.() ?? [];
const title = composedPath.find(
(node): node is HTMLElement =>
node instanceof HTMLElement && node.hasAttribute("data-title"),
);
const filePath = title?.textContent?.trim();
if (filePath) openDiffFile(filePath);
}}
>
<AnnotatableCodeView
viewerRef={codeViewRef}
key={collapseScopeKey ?? reviewSectionId}
className="diff-render-surface h-full min-h-0 overflow-auto"
files={codeViewFiles}
sectionId={reviewSectionId}
sectionTitle={reviewSectionTitle}
composerDraftTarget={composerDraftTarget}
renderHeaderPrefix={(fileDiff, fileKey, collapsed) => {
const filePath = resolveFileDiffPath(fileDiff);
return (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
className={cn(
"inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 transition-colors hover:bg-foreground/10 focus-visible:outline-hidden",
getDiffCollapseIconClassName(fileDiff),
)}
aria-label={collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`}
aria-expanded={!collapsed}
onClick={(event) => {
event.stopPropagation();
toggleDiffFileCollapsed(fileKey);
}}
/>
}
>
{collapsed ? (
<ChevronRightIcon className="size-4" />
) : (
<ChevronDownIcon className="size-4" />
)}
</TooltipTrigger>
<TooltipPopup side="top">
{collapsed ? "Expand diff" : "Collapse diff"}
</TooltipPopup>
</Tooltip>
<div className="flex min-h-0 flex-1 overflow-hidden">
<div
className="min-h-0 min-w-0 flex-1"
onClickCapture={(event) => {
const composedPath = event.nativeEvent.composedPath?.() ?? [];
const title = composedPath.find(
(node): node is HTMLElement =>
node instanceof HTMLElement && node.hasAttribute("data-title"),
);
const filePath = title?.textContent?.trim();
if (filePath) openDiffFile(filePath);
}}
options={{
diffStyle: diffRenderMode === "split" ? "split" : "unified",
lineDiffType: "none",
overflow: wordWrap ? "wrap" : "scroll",
theme: resolveDiffThemeName(resolvedTheme),
themeType: resolvedTheme as DiffThemeType,
unsafeCSS: DIFF_PANEL_UNSAFE_CSS,
stickyHeaders: true,
itemMetrics: { diffHeaderHeight: 33 },
layout: { paddingTop: 0, paddingBottom: 8, gap: 8 },
}}
/>
>
<AnnotatableCodeView
viewerRef={codeViewRef}
key={collapseScopeKey ?? reviewSectionId}
className="diff-render-surface h-full min-h-0 overflow-auto"
files={codeViewFiles}
sectionId={reviewSectionId}
sectionTitle={reviewSectionTitle}
composerDraftTarget={composerDraftTarget}
renderHeaderPrefix={(fileDiff, fileKey, collapsed) => {
const filePath = resolveFileDiffPath(fileDiff);
return (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
className={cn(
"inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 transition-colors hover:bg-foreground/10 focus-visible:outline-hidden",
getDiffCollapseIconClassName(fileDiff),
)}
aria-label={
collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`
}
aria-expanded={!collapsed}
onClick={(event) => {
event.stopPropagation();
toggleDiffFileCollapsed(fileKey);
}}
/>
}
>
{collapsed ? (
<ChevronRightIcon className="size-4" />
) : (
<ChevronDownIcon className="size-4" />
)}
</TooltipTrigger>
<TooltipPopup side="top">
{collapsed ? "Expand diff" : "Collapse diff"}
</TooltipPopup>
</Tooltip>
);
}}
options={{
diffStyle: diffRenderMode === "split" ? "split" : "unified",
lineDiffType: "none",
overflow: wordWrap ? "wrap" : "scroll",
theme: resolveDiffThemeName(resolvedTheme),
themeType: resolvedTheme as DiffThemeType,
unsafeCSS: DIFF_PANEL_UNSAFE_CSS,
stickyHeaders: true,
itemMetrics: { diffHeaderHeight: 33 },
layout: { paddingTop: 0, paddingBottom: 8, gap: 8 },
}}
/>
</div>
{fileTreeOpen ? (
<aside className="flex w-[min(16rem,40%)] min-w-40 shrink-0 border-l border-border/60">
<DiffFileTree
ariaLabel={`${reviewSectionTitle} files`}
entries={fileTreeEntries}
selectedPath={selectedFilePath}
revealRequestId={selectedFileRevealRequestId}
onSelectFile={revealDiffFile}
/>
</aside>
) : null}
</div>
) : (
<div className="min-h-0 flex-1 overflow-auto p-2">
Expand Down
17 changes: 12 additions & 5 deletions apps/web/src/components/chat/ExpandedImageDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { memo, useCallback, useEffect, useState } from "react";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react";
import { Button } from "../ui/button";
import type { ExpandedImagePreview } from "./ExpandedImagePreview";
import { ZoomableImage, type ZoomableImageHandle } from "./ZoomableImage";

interface ExpandedImageDialogProps {
preview: ExpandedImagePreview;
Expand All @@ -13,6 +14,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
onClose,
}: ExpandedImageDialogProps) {
const [imageOffset, setImageOffset] = useState(0);
const zoomableImageRef = useRef<ZoomableImageHandle>(null);
const index = (preview.index + imageOffset + preview.images.length) % preview.images.length;

const navigateImage = useCallback((direction: -1 | 1) => {
Expand All @@ -27,6 +29,11 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
onClose();
return;
}
if (zoomableImageRef.current?.pan(event.key)) {
event.preventDefault();
event.stopPropagation();
return;
}
if (preview.images.length <= 1) return;
if (event.key === "ArrowLeft") {
event.preventDefault();
Expand Down Expand Up @@ -82,11 +89,11 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
>
<XIcon />
</Button>
<img
<ZoomableImage
ref={zoomableImageRef}
key={`${index}:${item.src}`}
src={item.src}
alt={item.name}
className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl"
draggable={false}
name={item.name}
/>
<p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80">
{item.name}
Expand Down
Loading
Loading