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
47 changes: 22 additions & 25 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,14 @@ import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/proje
import { truncate } from "@t3tools/shared/String";
import { Debouncer } from "@tanstack/react-pacer";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { useNavigate } from "@tanstack/react-router";
import { useShallow } from "zustand/react/shallow";
import { useGitStatus } from "~/lib/gitStatusState";
import { usePrimaryEnvironmentId } from "../environments/primary";
import { readEnvironmentApi } from "../environmentApi";
import { isElectron } from "../env";
import { readLocalApi } from "../localApi";
import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
import { stripDiffSearchParams } from "../diffRouteSearch";
import {
collapseExpandedComposerCursor,
parseStandaloneComposerSlashCommand,
Expand Down Expand Up @@ -634,10 +634,6 @@ export default function ChatView(props: ChatViewProps) {
const timestampFormat = settings.timestampFormat;
const autoOpenPlanSidebar = settings.autoOpenPlanSidebar;
const navigate = useNavigate();
const rawSearch = useSearch({
strict: false,
select: (params) => parseDiffRouteSearch(params),
});
const { resolvedTheme } = useTheme();
// Granular store selectors — avoid subscribing to prompt changes.
const composerRuntimeMode = useComposerDraftStore(
Expand Down Expand Up @@ -808,13 +804,16 @@ export default function ChatView(props: ChatViewProps) {
composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;
const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
const canCheckoutPullRequestIntoThread = isLocalDraftThread;
const diffOpen = rawSearch.diff === "1";
const activeThreadId = activeThread?.id ?? null;
const activeThreadRef = useMemo(
() => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null),
[activeThread],
);
const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
const diffOpen = useUiStateStore((store) =>
activeThreadKey ? store.threadDiffOpenById[activeThreadKey] === true : false,
);
const setThreadDiffOpen = useUiStateStore((store) => store.setThreadDiffOpen);
const existingOpenTerminalThreadKeys = useMemo(() => {
const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);
return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));
Expand Down Expand Up @@ -1698,25 +1697,14 @@ export default function ChatView(props: ChatViewProps) {
[keybindings, nonTerminalShortcutLabelOptions],
);
const onToggleDiff = useCallback(() => {
if (!isServerThread) {
if (!isServerThread || !activeThreadKey) {
return;
}
if (!diffOpen) {
onDiffPanelOpen?.();
}
void navigate({
to: "/$environmentId/$threadId",
params: {
environmentId,
threadId,
},
replace: true,
search: (previous) => {
const rest = stripDiffSearchParams(previous);
return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" };
},
});
}, [diffOpen, environmentId, isServerThread, navigate, onDiffPanelOpen, threadId]);
setThreadDiffOpen(activeThreadKey, !diffOpen);
}, [activeThreadKey, diffOpen, isServerThread, onDiffPanelOpen, setThreadDiffOpen]);

const envLocked = Boolean(
activeThread &&
Expand Down Expand Up @@ -3456,10 +3444,11 @@ export default function ChatView(props: ChatViewProps) {
}, []);
const onOpenTurnDiff = useCallback(
(turnId: TurnId, filePath?: string) => {
if (!isServerThread) {
if (!isServerThread || !activeThreadKey) {
return;
}
onDiffPanelOpen?.();
setThreadDiffOpen(activeThreadKey, true);
void navigate({
to: "/$environmentId/$threadId",
params: {
Expand All @@ -3469,12 +3458,20 @@ export default function ChatView(props: ChatViewProps) {
search: (previous) => {
const rest = stripDiffSearchParams(previous);
return filePath
? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath }
: { ...rest, diff: "1", diffTurnId: turnId };
? { ...rest, diffTurnId: turnId, diffFilePath: filePath }
: { ...rest, diffTurnId: turnId };
},
});
},
[environmentId, isServerThread, navigate, onDiffPanelOpen, threadId],
[
activeThreadKey,
environmentId,
isServerThread,
navigate,
onDiffPanelOpen,
setThreadDiffOpen,
threadId,
],
);
// Both the Map and the revert handler are read from refs at call-time so
// the callback reference is fully stable and never busts context identity.
Expand Down
125 changes: 81 additions & 44 deletions apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import { parsePatchFiles } from "@pierre/diffs";
import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
import { scopeThreadRef } from "@t3tools/client-runtime";
import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
import type { TurnId } from "@t3tools/contracts";
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftRightIcon,
ChevronsRightLeftIcon,
Columns2Icon,
PilcrowIcon,
Rows3Icon,
Expand All @@ -33,14 +35,14 @@ import { buildPatchCacheKey } from "../lib/diffRendering";
import { resolveDiffThemeName } from "../lib/diffRendering";
import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
import { selectProjectByRef, useStore } from "../store";
import { useUiStateStore } from "../uiStateStore";
import { createThreadSelectorByRef } from "../storeSelectors";
import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
import { useSettings } from "../hooks/useSettings";
import { formatShortTimestamp } from "../timestampFormat";
import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
import { ToggleGroup, Toggle } from "./ui/toggle-group";

type DiffRenderMode = "stacked" | "split";
type DiffThemeType = "light" | "dark";

const DIFF_PANEL_UNSAFE_CSS = `
Expand Down Expand Up @@ -187,27 +189,49 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
const navigate = useNavigate();
const { resolvedTheme } = useTheme();
const settings = useSettings();
const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap);
const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace);
const [collapsedDiffFileKeys, setCollapsedDiffFileKeys] = useState<ReadonlySet<string>>(
() => new Set(),
);
const diffRenderMode = useUiStateStore((store) => store.diffRenderMode);
const setDiffRenderMode = useUiStateStore((store) => store.setDiffRenderMode);
const diffWordWrap = useUiStateStore((store) => store.diffWordWrap);
const setDiffWordWrap = useUiStateStore((store) => store.setDiffWordWrap);
const diffIgnoreWhitespace = useUiStateStore((store) => store.diffIgnoreWhitespace);
const setDiffIgnoreWhitespace = useUiStateStore((store) => store.setDiffIgnoreWhitespace);
const hydrateDiffSettings = useUiStateStore((store) => store.hydrateDiffSettings);
const patchViewportRef = useRef<HTMLDivElement>(null);
const turnStripRef = useRef<HTMLDivElement>(null);
const previousDiffOpenRef = useRef(false);
const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false);
const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false);
const routeThreadRef = useParams({
strict: false,
select: (params) => resolveThreadRouteRef(params),
});
const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) });
const diffOpen = diffSearch.diff === "1";
const activeThreadId = routeThreadRef?.threadId ?? null;
const activeThread = useStore(
useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]),
);
const activeThreadKey = useMemo(
() => (routeThreadRef ? scopedThreadKey(routeThreadRef) : null),
[routeThreadRef],
);
const diffFullWidth = useUiStateStore((store) =>
activeThreadKey ? store.threadDiffFullWidthById[activeThreadKey] === true : false,
);
const setThreadDiffFullWidth = useUiStateStore((store) => store.setThreadDiffFullWidth);
const collapsedFilePaths = useUiStateStore((store) =>
activeThreadKey ? store.threadDiffFileCollapsedById[activeThreadKey] : undefined,
);
const setDiffFileCollapsed = useUiStateStore((store) => store.setDiffFileCollapsed);
const isFileCollapsed = useCallback(
(filePath: string) => collapsedFilePaths?.[filePath] === true,
[collapsedFilePaths],
);
const toggleFileCollapsed = useCallback(
(filePath: string) => {
if (!activeThreadKey) return;
setDiffFileCollapsed(activeThreadKey, filePath, !isFileCollapsed(filePath));
},
[activeThreadKey, isFileCollapsed, setDiffFileCollapsed],
);
const activeProjectId = activeThread?.projectId ?? null;
const activeProject = useStore((store) =>
activeThread && activeProjectId
Expand Down Expand Up @@ -336,26 +360,17 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
);
}, [renderablePatch]);

// Seed the global diff-view store from the user's persistent settings the
// first time a DiffPanel mounts in this session. Subsequent calls are
// no-ops, so toggling wrap / ignore-whitespace in the panel is preserved
// across remounts (e.g. the expand/collapse remount that swaps panel
// parents).
useEffect(() => {
if (renderableFiles.length === 0) {
setCollapsedDiffFileKeys((current) => (current.size === 0 ? current : new Set()));
return;
}

const visibleFileKeys = new Set(renderableFiles.map(buildFileDiffRenderKey));
setCollapsedDiffFileKeys((current) => {
const next = new Set([...current].filter((fileKey) => visibleFileKeys.has(fileKey)));
return next.size === current.size ? current : next;
hydrateDiffSettings({
diffWordWrap: settings.diffWordWrap,
diffIgnoreWhitespace: settings.diffIgnoreWhitespace,
});
}, [renderableFiles]);

useEffect(() => {
if (diffOpen && !previousDiffOpenRef.current) {
setDiffWordWrap(settings.diffWordWrap);
setDiffIgnoreWhitespace(settings.diffIgnoreWhitespace);
}
previousDiffOpenRef.current = diffOpen;
}, [diffOpen, settings.diffIgnoreWhitespace, settings.diffWordWrap]);
}, [hydrateDiffSettings, settings.diffWordWrap, settings.diffIgnoreWhitespace]);

useEffect(() => {
if (!selectedFilePath || !patchViewportRef.current) {
Expand All @@ -378,17 +393,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
},
[activeCwd],
);
const toggleDiffFileCollapsed = useCallback((fileKey: string) => {
setCollapsedDiffFileKeys((current) => {
const next = new Set(current);
if (next.has(fileKey)) {
next.delete(fileKey);
} else {
next.add(fileKey);
}
return next;
});
}, []);

const selectTurn = (turnId: TurnId) => {
if (!activeThread) return;
Expand All @@ -397,7 +401,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)),
search: (previous) => {
const rest = stripDiffSearchParams(previous);
return { ...rest, diff: "1", diffTurnId: turnId };
return { ...rest, diffTurnId: turnId };
},
});
};
Expand All @@ -407,11 +411,14 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
to: "/$environmentId/$threadId",
params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)),
search: (previous) => {
const rest = stripDiffSearchParams(previous);
return { ...rest, diff: "1" };
return stripDiffSearchParams(previous);
},
});
};
const toggleDiffFullWidth = () => {
if (!activeThreadKey) return;
setThreadDiffFullWidth(activeThreadKey, !diffFullWidth);
};
const updateTurnStripScrollState = useCallback(() => {
const element = turnStripRef.current;
if (!element) {
Expand Down Expand Up @@ -610,6 +617,26 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
>
<PilcrowIcon className="size-3" />
</Toggle>
{mode !== "sheet" ? (
<Toggle
aria-label={
diffFullWidth ? "Restore split pane diff view" : "Expand diff view to full width"
}
title={diffFullWidth ? "Restore split pane" : "Expand to full width"}
variant="outline"
size="xs"
pressed={diffFullWidth}
onPressedChange={() => {
toggleDiffFullWidth();
}}
>
{diffFullWidth ? (
<ChevronsRightLeftIcon className="size-3" />
) : (
<ChevronsLeftRightIcon className="size-3" />
)}
</Toggle>
) : null}
</div>
</>
);
Expand Down Expand Up @@ -663,15 +690,24 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
const filePath = resolveFileDiffPath(fileDiff);
const fileKey = buildFileDiffRenderKey(fileDiff);
const themedFileKey = `${fileKey}:${resolvedTheme}`;
const collapsed = collapsedDiffFileKeys.has(fileKey);
const collapsed = isFileCollapsed(filePath);
return (
<div
key={themedFileKey}
data-diff-file-path={filePath}
data-diff-file-collapsed={collapsed ? "true" : undefined}
className="diff-render-file group/diff-file mb-2 rounded-md first:mt-2 last:mb-0"
onClickCapture={(event) => {
const nativeEvent = event.nativeEvent as MouseEvent;
const composedPath = nativeEvent.composedPath?.() ?? [];
// A click that originated from (or passed through) the
// collapse button must not also trigger the open-in-editor
// behavior bound to clicks on the file title.
const clickedCollapseButton = composedPath.some((node) => {
if (!(node instanceof Element)) return false;
return node.hasAttribute("data-diff-file-collapse-button");
});
if (clickedCollapseButton) return;
const clickedHeader = composedPath.some((node) => {
if (!(node instanceof Element)) return false;
return node.hasAttribute("data-title");
Expand All @@ -685,6 +721,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
renderHeaderPrefix={() => (
<button
type="button"
data-diff-file-collapse-button="true"
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),
Expand All @@ -694,7 +731,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
title={collapsed ? "Expand diff" : "Collapse diff"}
onClick={(event) => {
event.stopPropagation();
toggleDiffFileCollapsed(fileKey);
toggleFileCollapsed(filePath);
}}
>
{collapsed ? (
Expand Down
Loading
Loading