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
6 changes: 6 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1712,6 +1712,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?.workingTree.rootPath ??
(activeThreadWorktreePath ? undefined : activeProject?.repositoryIdentity?.rootPath);
const activeTerminalLaunchContext =
terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null;
const liveIsGitRepo = gitStatusQuery.data?.isRepo;
Expand Down Expand Up @@ -3507,6 +3512,7 @@ export default function ChatView(props: ChatViewProps) {
resolvedTheme={resolvedTheme}
timestampFormat={timestampFormat}
workspaceRoot={activeWorkspaceRoot}
repositoryRoot={activeRepositoryRoot}
skills={timelineSkills}
anchorMessageId={timelineAnchorMessageId}
onAnchorReady={onTimelineAnchorReadyStable}
Expand Down
94 changes: 92 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,82 @@ 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("opens a file from a tool call's diff by the path its agent recorded", async () => {
using context = setup("origin/orphan");
inRepositoryFolder(context, true);
// Claude's Edit can record a path relative to the workspace, unlike the repository's diffs.
state.toolCallItem = {
type: "file_change",
fileName: "src/a.ts",
diffStr: "--- src/a.ts\n+++ src/a.ts\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"));
});
15 changes: 10 additions & 5 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 @@ -727,7 +732,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 +1422,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 @@ -1031,6 +1034,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
Loading