Skip to content
6 changes: 6 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1688,6 +1688,11 @@ export default function ChatView(props: ChatViewProps) {
const activeProjectCwd = activeProject?.workspaceRoot ?? null;
const activeThreadWorktreePath = activeThread?.worktreePath ?? null;
const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined;
// Git status knows the root without a remote; the repository identity covers it while
// status loads or fails. A worktree thread's root is its cwd.
const activeRepositoryRoot =
gitStatusQuery.data?.rootPath ??
(activeThreadWorktreePath ? undefined : activeProject?.repositoryIdentity?.rootPath);
const activeTerminalLaunchContext =
terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null;
const liveIsGitRepo = gitStatusQuery.data?.isRepo;
Expand Down Expand Up @@ -3448,6 +3453,7 @@ export default function ChatView(props: ChatViewProps) {
resolvedTheme={resolvedTheme}
timestampFormat={timestampFormat}
workspaceRoot={activeWorkspaceRoot}
repositoryRoot={activeRepositoryRoot}
skills={timelineSkills}
anchorMessageId={timelineAnchorMessageId}
onAnchorReady={onTimelineAnchorReadyStable}
Expand Down
97 changes: 95 additions & 2 deletions apps/web/src/components/DiffPanel.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
EnvironmentId,
ProjectId,
ThreadId,
TurnItemId,
type ClientSettings,
type ReviewBranchCommitsResult,
type ReviewDiffPreviewResult,
Expand All @@ -31,6 +32,8 @@ const state = vi.hoisted(() => ({
patch: null as unknown as ResultAtom<ReviewDiffPreviewSource>,
diffFilesCollapsed: false,
config: null as unknown as Atom.Atom<null>,
project: { workspaceRoot: "/project", repositoryIdentity: null as { rootPath: string } | null },
toolCallItem: null as unknown,
status: null as unknown as ResultAtom<VcsStatusResult>,
localRefs: null as unknown as ResultAtom<VcsListRefsResult>,
remoteRefs: null as unknown as ResultAtom<VcsListRefsResult>,
Expand Down Expand Up @@ -58,7 +61,7 @@ vi.mock("~/state/vcs", () => ({
}));
vi.mock("~/state/entities", () => ({
useQuietThreadShell: () => state.thread,
useProject: () => ({ workspaceRoot: "/project", repositoryIdentity: null }),
useProject: () => state.project,
useThreadCheckpointSummaries: () => [],
useQuietThreadVisibleTurnItems: () => [],
}));
Expand All @@ -78,7 +81,11 @@ vi.mock("~/hooks/useTheme", () => ({
}));
vi.mock("~/state/queries", () => ({
useCheckpointDiff: () => ({ data: null, error: null, isPending: false }),
useTurnItemDetail: () => ({ data: null, error: null, isPending: false }),
useTurnItemDetail: () => ({
data: state.toolCallItem ? { item: state.toolCallItem } : null,
error: null,
isPending: false,
}),
useDebouncedValue: <A,>(value: A) => value,
}));
vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => async () => {} }));
Expand All @@ -91,6 +98,7 @@ vi.mock("./DiffFilePathCopyButton", () => ({ DiffFilePathCopyButton: () => null

import DiffPanel from "./DiffPanel";
import { useDiffPanelSessionStore, useDiffPanelStore } from "~/diffPanelStore";
import { useRightPanelStore } from "~/rightPanelStore";

const cwd = "/project-worktree";
const errorMessage = "Could not find a common commit between 'origin/orphan' and HEAD.";
Expand Down Expand Up @@ -124,9 +132,12 @@ function setup(failedBaseRef: string | null) {
keepSelectionOnOpenThreadKey: null,
});
useDiffPanelSessionStore.setState({ collapsedFileKeysByScopeKey: {} });
useRightPanelStore.setState({ byThreadKey: {} });
};
reset();
state.diffFilesCollapsed = false;
state.project = { workspaceRoot: "/project", repositoryIdentity: null };
state.toolCallItem = null;
const patch = Atom.make<AsyncResult.AsyncResult<ReviewDiffPreviewSource, Error>>(
AsyncResult.initial(),
);
Expand Down Expand Up @@ -268,3 +279,85 @@ it.each([false, true])(
await expect.element(toggle).toHaveAttribute("aria-expanded", "false");
},
);

// A local thread in a project that is the repository's apps/web folder.
function inRepositoryFolder(context: ReturnType<typeof setup>, repositoryIdentity: boolean) {
state.thread = { ...state.thread!, worktreePath: null };
state.project = {
workspaceRoot: "/repo/apps/web",
repositoryIdentity: repositoryIdentity ? { rootPath: "/repo" } : null,
};
const status = AsyncResult.getOrThrow(context.registry.get(state.status));
state.status = Atom.make(
AsyncResult.success({ ...status, workingTree: { ...status.workingTree, rootPath: "/repo" } }),
);
}

function openedFiles(threadRef: ScopedThreadRef) {
return (
useRightPanelStore
.getState()
.byThreadKey[scopedThreadKey(threadRef)]?.surfaces.map((surface) => surface.id) ?? []
);
}

it("opens a file from a diff in a project inside its repository", async () => {
using context = setup("origin/orphan");
// Without a remote the project has no repository identity.
inRepositoryFolder(context, false);
const path = "apps/web/src/a.ts";
const metadata = preview();
const source = {
...metadata.sources[0]!,
files: [{ path, previousPath: null, additions: 1, deletions: 1 }],
};
state.previews.set("main", Atom.make(AsyncResult.success({ ...metadata, sources: [source] })));
useDiffPanelStore.getState().selectBranchBaseRef(context.threadRef, "main");
await using screen = await render(
<RegistryContext.Provider value={context.registry}>
<div style={{ height: 600, display: "flex", flexDirection: "column" }}>
<DiffPanel composerDraftTarget={context.threadRef} workspaceMutationId={null} visible />
</div>
</RegistryContext.Provider>,
);
await userEvent.click(screen.getByRole("button", { name: `Expand ${path}`, exact: true }));
await act(() =>
context.registry.set(
context.patch,
AsyncResult.success({
...source,
diff: `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -1 +1 @@\n-before\n+after\n`,
}),
),
);
await userEvent.click(screen.getByText(path, { exact: true }));
await vi.waitFor(() => expect(openedFiles(context.threadRef)).toContain("file:src/a.ts"));
});

it.each([
{ name: "relative to the workspace", path: "src/a.ts" },
{ name: "absolute", path: "/repo/apps/web/src/a.ts" },
])("opens a file from a tool call's diff that recorded it $name", async ({ path }) => {
using context = setup("origin/orphan");
inRepositoryFolder(context, true);
// Claude's Edit records either; the header names both relative to the workspace.
state.toolCallItem = {
type: "file_change",
fileName: path,
diffStr: `--- ${path}\n+++ ${path}\n@@ -1 +1 @@\n-before\n+after\n`,
};
useDiffPanelStore.getState().selectToolCall(context.threadRef, {
threadId: context.threadRef.threadId,
itemId: TurnItemId.make("edit"),
revision: "1",
});
await using screen = await render(
<RegistryContext.Provider value={context.registry}>
<div style={{ height: 600, display: "flex", flexDirection: "column" }}>
<DiffPanel composerDraftTarget={context.threadRef} workspaceMutationId={null} visible />
</div>
</RegistryContext.Provider>,
);
await userEvent.click(screen.getByText("src/a.ts", { exact: true }));
await vi.waitFor(() => expect(openedFiles(context.threadRef)).toContain("file:src/a.ts"));
});
19 changes: 13 additions & 6 deletions apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,9 +257,6 @@ export default function DiffPanel({
: null,
);
const activeCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot;
const activeRepositoryRoot = activeThread?.worktreePath
? undefined
: activeProject?.repositoryIdentity?.rootPath;
const serverConfig = useAtomValue(
serverEnvironment.configValueAtom(activeThread?.environmentId ?? null),
);
Expand All @@ -277,13 +274,21 @@ export default function DiffPanel({
})
: null,
);
// Git status knows the root without a remote; the repository identity covers it while
// status loads or fails. A worktree thread's root is its cwd.
const activeRepositoryRoot =
gitStatusQuery.data?.workingTree.rootPath ??
(activeThread?.worktreePath ? undefined : activeProject?.repositoryIdentity?.rootPath);
const diffSelection = useDiffPanelStore((state) =>
selectThreadDiffPanelSelection(state.byThreadKey, routeThreadRef),
);
const rememberedBaseRef = useDiffPanelStore((state) =>
selectThreadBranchBaseRef(state, routeThreadRef),
);
const toolCallSelection = diffSelection.kind === "tool-call" ? diffSelection : null;
// Turn and git diffs name files from the repository root; a tool call's diff names them as
// its provider recorded them, absolute or relative to the workspace.
const diffPathRepositoryRoot = toolCallSelection ? undefined : activeRepositoryRoot;
const isGitSelection =
diffSelection.kind === "branch" ||
diffSelection.kind === "unstaged" ||
Expand Down Expand Up @@ -470,7 +475,9 @@ export default function DiffPanel({
const gitDiff = selectedGitSource?.diff;

const toolCallPreview =
toolCallSelection && toolCallItem?.type === "file_change" ? toolCallDiff(toolCallItem) : null;
toolCallSelection && toolCallItem?.type === "file_change"
? toolCallDiff(toolCallItem, activeCwd)
: null;
const selectedPatch = toolCallSelection
? toolCallPreview?.patch
: selectedTurn
Expand Down Expand Up @@ -727,7 +734,7 @@ export default function DiffPanel({
threadRef: routeThreadRef,
filePath,
activeCwd,
repositoryRoot: activeRepositoryRoot,
repositoryRoot: diffPathRepositoryRoot,
openInEditor: (targetPath) => {
void (async () => {
const result = await openInPreferredEditor(targetPath);
Expand Down Expand Up @@ -1417,7 +1424,7 @@ export default function DiffPanel({
environmentId: activeThread?.environmentId ?? null,
filePath,
workspaceRoot: activeCwd,
repositoryRoot: activeRepositoryRoot,
repositoryRoot: diffPathRepositoryRoot,
},
event,
);
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,8 @@ interface MessagesTimelineProps {
resolvedTheme: "light" | "dark";
timestampFormat: TimestampFormat;
workspaceRoot: string | undefined;
/** Diff paths are named from it; omitted, they are taken as workspace-relative. */
repositoryRoot?: string | undefined;
skills?: ReadonlyArray<ServerProviderSkill>;
runs: ReadonlyArray<HandoffTimelineRun>;
anchorMessageId: MessageId | null;
Expand Down Expand Up @@ -391,6 +393,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
resolvedTheme,
timestampFormat,
workspaceRoot,
repositoryRoot,
skills = EMPTY_TIMELINE_SKILLS,
runs: runsProp,
anchorMessageId,
Expand Down Expand Up @@ -999,6 +1002,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
markdownCwd,
resolvedTheme,
workspaceRoot,
repositoryRoot,
skills,
runs,
activeThreadEnvironmentId,
Expand Down
13 changes: 2 additions & 11 deletions apps/web/src/components/chat/TimelineAssistantRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { McpAppFrame } from "./McpAppFrame";
import { checkpointFileTotals } from "@t3tools/client-runtime/state/thread-checkpoints";
import { ChangedFilesCard } from "./ChangedFilesTree";
import { useFileContextMenuHandler } from "../../fileContextMenu";
import { useAssistantMessageText, useProject, useThreadShell } from "../../state/entities";
import { useAssistantMessageText } from "../../state/entities";
import { MessageCopyButton } from "./MessageCopyButton";
import { AssistantCitationSource } from "./AssistantCitationSource";
import { resolveAssistantMessageCopyState } from "./MessagesTimeline.logic";
Expand Down Expand Up @@ -342,12 +342,6 @@ function AssistantChangedFilesSectionInner({
const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded);
const allDirectoriesExpanded = persistedExpanded ?? false;

const thread = useThreadShell(ctx.threadRef);
const activeProject = useProject(
thread && thread.projectId
? { environmentId: thread.environmentId, projectId: thread.projectId }
: null,
);
const onFileContextMenu = useFileContextMenuHandler(ctx.activeThreadEnvironmentId);

return (
Expand All @@ -367,10 +361,7 @@ function AssistantChangedFilesSectionInner({
environmentId: ctx.activeThreadEnvironmentId,
filePath,
workspaceRoot: ctx.workspaceRoot,
repositoryRoot:
thread?.worktreePath == null
? activeProject?.repositoryIdentity?.rootPath
: undefined,
repositoryRoot: ctx.repositoryRoot,
},
event,
)
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/chat/timelineRowContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export interface TimelineRowSharedState {
markdownCwd: string | undefined;
resolvedTheme: "light" | "dark";
workspaceRoot: string | undefined;
repositoryRoot: string | undefined;
skills: ReadonlyArray<ServerProviderSkill>;
runs: ReadonlyArray<HandoffTimelineRun>;
activeThreadEnvironmentId: EnvironmentId;
Expand Down
50 changes: 50 additions & 0 deletions apps/web/src/lib/toolCallDiff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,56 @@ describe("toolCallDiff", () => {
});
});

it("names files under the workspace relative to it, as recorded relative paths are", () => {
const root = "/Users/me/project";
const paths = (patch: string | undefined) => {
const rendered = getRenderablePatch(patch);
if (rendered?.kind !== "files") throw new Error("Expected a file diff");
return rendered.files.map((file) => resolveFileDiffPath(file));
};
const recorded = `--- ${root}/docs/plan.md\n+++ ${root}/docs/plan.md\n@@ -1 +1 @@\n-a ${root}/x\n+b\n`;
const { patch } = toolCallDiff({ fileName: `${root}/docs/plan.md`, diffStr: recorded }, root);
expect(paths(patch)).toEqual(["docs/plan.md"]);
expect(patch).toContain(`-a ${root}/x`);
expect(
paths(
toolCallDiff({ fileName: `${root}/src/a.ts`, diffStr: "@@ -1 +1 @@\n-x\n+y\n" }, `${root}/`)
.patch,
),
).toEqual(["src/a.ts"]);
expect(
paths(
toolCallDiff({ fileName: "/elsewhere/b.ts", diffStr: "@@ -1 +1 @@\n-x\n+y\n" }, root).patch,
),
).toEqual(["/elsewhere/b.ts"]);

// Only where a header's path starts: hunk lines that look like headers, and a path that
// merely contains the root, keep it, in this file and the next.
const twoFiles = [
`--- ${root}/a.ts`,
`+++ ${root}/a.ts`,
"@@ -1 +1 @@",
`--- ${root}/x`,
`+++ ${root}/x`,
`--- /backup${root}/b.ts`,
`+++ /backup${root}/b.ts`,
"@@ -1 +1 @@",
"-b",
"+c",
"",
].join("\n");
const multiple = toolCallDiff({ fileName: `${root}/a.ts`, diffStr: twoFiles }, root).patch;
expect(paths(multiple)).toEqual(["a.ts", `/backup${root}/b.ts`]);
expect(multiple).toContain(`\n--- ${root}/x\n+++ ${root}/x\n`);

// Windows paths, which a quoted header escapes.
const windows = toolCallDiff(
{ fileName: "C:\\repo\\src\\a.ts", diffStr: "@@ -1 +1 @@\n-x\n+y\n" },
"c:\\repo",
).patch;
expect(paths(windows)).toEqual(["src\\a.ts"]);
});

it("preserves complete patches and unsupported provider output", () => {
for (const operation of ["add", "delete"]) {
const diffStr = [
Expand Down
Loading
Loading