Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
22 changes: 22 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ import {
type PendingUserInputDraftAnswer,
} from "../pendingUserInput";
import { useUiStateStore } from "../uiStateStore";
import {
latestWorkspaceMutationId,
useWorkspaceMutationRefresh,
} from "../hooks/useWorkspaceMutationRefresh";
import {
buildPlanImplementationThreadTitle,
buildPlanImplementationPrompt,
Expand Down Expand Up @@ -2343,6 +2347,13 @@ function ChatViewContent(props: ChatViewProps) {
const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;
const phase = derivePhase(activeThread?.session ?? null);
const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
const latestCheckpointCompletedAt = activeThread?.checkpoints.at(-1)?.completedAt ?? null;
const workspaceMutationId = useMemo(() => {
const activityId = latestWorkspaceMutationId(threadActivities);
return activityId === null && latestCheckpointCompletedAt === null
? null
: JSON.stringify([activityId, latestCheckpointCompletedAt]);
}, [latestCheckpointCompletedAt, threadActivities]);
const activeContextWindow = useMemo(
() => deriveLatestContextWindowSnapshot(threadActivities),
[threadActivities],
Expand Down Expand Up @@ -2896,6 +2907,12 @@ function ChatViewContent(props: ChatViewProps) {
input: { cwd: gitStatusCwd },
}),
);
useWorkspaceMutationRefresh({
enabled: gitStatusCwd !== null,
mutationId: workspaceMutationId,
refresh: gitStatusQuery.refresh,
resourceKey: `git-status:${activeThreadKey ?? ""}:${gitStatusCwd ?? ""}`,
});
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom);
// Prefer an instance-id match so a custom Codex instance (e.g.
Expand Down Expand Up @@ -7027,6 +7044,7 @@ function ChatViewContent(props: ChatViewProps) {
mode="embedded"
composerDraftTarget={composerDraftTarget}
initialGitScope={initialDiffPanelGitScope}
workspaceMutationId={workspaceMutationId}
/>
</Suspense>
) : activeRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? (
Expand Down Expand Up @@ -7095,6 +7113,10 @@ function ChatViewContent(props: ChatViewProps) {
revealRequestId={activeFileSurface?.revealRequestId ?? 0}
onOpenFile={openFileSurface}
onPendingChange={handleFilePendingChange}
selectedFilePending={
activeFileSurface !== null && pendingFileSurfaceIds.has(activeFileSurface.id)
}
workspaceMutationId={workspaceMutationId}
/>
</Suspense>
) : null
Expand Down
30 changes: 9 additions & 21 deletions apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
} from "../lib/diffRendering";
import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse";
import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh";
import { useProject, useThread } from "../state/entities";
import { resolveThreadRouteRef } from "../threadRoutes";
import { useClientSettings } from "../hooks/useSettings";
Expand Down Expand Up @@ -90,6 +91,7 @@ interface DiffPanelProps {
mode?: DiffPanelMode;
composerDraftTarget: ScopedThreadRef | DraftId;
initialGitScope: "branch" | "unstaged";
workspaceMutationId: string | null;
}

export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
Expand All @@ -98,6 +100,7 @@ export default function DiffPanel({
mode = "inline",
composerDraftTarget,
initialGitScope: initialGitScopeProp,
workspaceMutationId,
}: DiffPanelProps) {
const { resolvedTheme } = useTheme();
const settings = useClientSettings();
Expand All @@ -113,10 +116,6 @@ export default function DiffPanel({
}));
const [codeViewRevision, setCodeViewRevision] = useState(0);
const codeViewRef = useRef<AnnotatableCodeViewHandle>(null);
const lastCompletedTurnRefreshRef = useRef<{
readonly threadKey: string | null;
readonly turnId: TurnId | null;
} | null>(null);

const routeThreadRef = useParams({
strict: false,
Expand Down Expand Up @@ -290,23 +289,12 @@ export default function DiffPanel({
return () => window.removeEventListener("focus", refreshOnFocus);
}, [canRefreshGitDiff, refreshBranchDiffPreview]);

useEffect(() => {
const current = {
threadKey: activeThreadRefreshKey,
turnId: latestTurn?.turnId ?? null,
};
const previous = lastCompletedTurnRefreshRef.current;
if (!canRefreshGitDiff) {
return;
}
if (previous === null || previous.threadKey !== current.threadKey) {
lastCompletedTurnRefreshRef.current = current;
return;
}
if (previous.turnId === current.turnId) return;
refreshBranchDiffPreview();
lastCompletedTurnRefreshRef.current = current;
}, [activeThreadRefreshKey, canRefreshGitDiff, latestTurn?.turnId, refreshBranchDiffPreview]);
useWorkspaceMutationRefresh({
enabled: canRefreshGitDiff,
mutationId: workspaceMutationId,
refresh: refreshBranchDiffPreview,
resourceKey: `diff:${activeThreadRefreshKey ?? ""}`,
});

const selectedGitSource = branchDiffPreview.data?.sources.find(
(source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"),
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/components/files/FileBrowserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
import { useComposerHandleContext } from "~/composerHandleContext";
import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
import { useTheme } from "~/hooks/useTheme";
import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";
import { cn } from "~/lib/utils";
import { readLocalApi } from "~/localApi";
import { T3_PIERRE_ICONS } from "~/pierre-icons";
Expand All @@ -32,6 +33,7 @@ interface FileBrowserPanelProps {
selectedPathRevealId: number;
onOpenFile: (relativePath: string) => void;
onRefreshSelectedFile?: () => void;
workspaceMutationId: string | null;
}

const TREE_UNSAFE_CSS = `
Expand Down Expand Up @@ -107,6 +109,7 @@ export default function FileBrowserPanel({
selectedPathRevealId,
onOpenFile,
onRefreshSelectedFile,
workspaceMutationId,
}: FileBrowserPanelProps) {
const { resolvedTheme } = useTheme();
const composerRef = useComposerHandleContext();
Expand Down Expand Up @@ -260,6 +263,11 @@ export default function FileBrowserPanel({
entriesQuery.refresh();
onRefreshSelectedFile?.();
};
useWorkspaceMutationRefresh({
mutationId: workspaceMutationId,
refresh: entriesQuery.refresh,
resourceKey: `files:${environmentId}:${cwd}`,
});

useEffect(() => {
if (previousTreePathsRef.current === treePaths) return;
Expand Down
27 changes: 23 additions & 4 deletions apps/web/src/components/files/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { useRemoteOpenState } from "~/remoteOpen";
import { useClientSettings } from "~/hooks/useSettings";
import { useTheme } from "~/hooks/useTheme";
import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage";
import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";
import { DIFF_SURFACE_THEME_UNSAFE_CSS, resolveDiffThemeName } from "~/lib/diffRendering";
import { cn } from "~/lib/utils";
import { isPreviewSupportedInRuntime } from "~/previewStateStore";
Expand Down Expand Up @@ -78,6 +79,8 @@ interface FilePreviewPanelProps {
revealRequestId: number;
onOpenFile: (relativePath: string) => void;
onPendingChange: (relativePath: string, pending: boolean) => void;
selectedFilePending: boolean;
workspaceMutationId: string | null;
}

const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen";
Expand Down Expand Up @@ -133,29 +136,35 @@ function WorkspaceImagePreview(props: {
readonly threadRef: ScopedThreadRef;
readonly absolutePath: string;
readonly alt: string;
readonly workspaceMutationId: string | null;
}) {
const assetUrl = useAssetUrlState(props.environmentId, {
_tag: "workspace-file",
threadId: props.threadRef.threadId,
path: props.absolutePath,
});
const [failedUrl, setFailedUrl] = useState<string | null>(null);
const revisionSuffix =
props.workspaceMutationId === null
? ""
: `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`;
const imageUrl = assetUrl._tag === "Success" ? `${assetUrl.url}${revisionSuffix}` : null;

if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) {
if (assetUrl._tag === "Failure" || (imageUrl !== null && failedUrl === imageUrl)) {
return (
<div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive">
Unable to load workspace image.
</div>
);
}

return assetUrl._tag === "Success" ? (
return assetUrl._tag === "Success" && imageUrl !== null ? (
<div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4">
<img
className="max-h-full max-w-full object-contain"
src={assetUrl.url}
src={imageUrl}
alt={props.alt}
onError={() => setFailedUrl(assetUrl.url)}
onError={() => setFailedUrl(imageUrl)}
/>
</div>
) : (
Expand Down Expand Up @@ -768,6 +777,8 @@ export default function FilePreviewPanel({
revealRequestId,
onOpenFile,
onPendingChange,
selectedFilePending,
workspaceMutationId,
}: FilePreviewPanelProps) {
const { resolvedTheme } = useTheme();
const wordWrap = useClientSettings((settings) => settings.wordWrap);
Expand Down Expand Up @@ -812,6 +823,12 @@ export default function FilePreviewPanel({
[projectName, relativePath],
);
const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId);
useWorkspaceMutationRefresh({
enabled: relativePath !== null && !isImage && !selectedFilePending,
mutationId: workspaceMutationId,
refresh: file.refresh,
resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`,
});

useEffect(() => {
const currentCrumb = breadcrumbRef.current?.querySelector<HTMLElement>(
Expand Down Expand Up @@ -1001,6 +1018,7 @@ export default function FilePreviewPanel({
threadRef={threadRef}
absolutePath={absolutePath}
alt={relativePath}
workspaceMutationId={workspaceMutationId}
/>
) : relativePath && file.error && file.data === null ? (
<div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive">
Expand Down Expand Up @@ -1080,6 +1098,7 @@ export default function FilePreviewPanel({
selectedPath={relativePath}
selectedPathRevealId={revealRequestId}
onOpenFile={onOpenFile}
workspaceMutationId={workspaceMutationId}
{...(relativePath && !isImage ? { onRefreshSelectedFile: file.refresh } : {})}
/>
</aside>
Expand Down
62 changes: 62 additions & 0 deletions apps/web/src/hooks/useWorkspaceMutationRefresh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { EventId, type OrchestrationThreadActivity } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import {
latestWorkspaceMutationId,
workspaceMutationRefreshToken,
} from "./useWorkspaceMutationRefresh";

function activity(
id: string,
kind: string,
itemType: string,
status?: string,
): OrchestrationThreadActivity {
return {
id: EventId.make(id),
kind,
tone: "tool",
summary: "Tool activity",
payload: { itemType, ...(status ? { status } : {}) },
turnId: null,
createdAt: "2026-08-30T00:00:00.000Z",
};
}

describe("workspace mutation refresh", () => {
it("tracks the latest completed file change or command", () => {
expect(
latestWorkspaceMutationId([
activity("file-started", "tool.started", "file_change"),
activity("search-completed", "tool.completed", "web_search"),
activity("file-completed", "tool.completed", "file_change"),
activity("command-completed", "tool.completed", "command_execution"),
]),
).toBe("command-completed");
});

it("ignores read-only and in-progress tools", () => {
expect(
latestWorkspaceMutationId([
activity("command-updated", "tool.updated", "command_execution", "inProgress"),
activity("legacy-command-updated", "tool.updated", "command_execution", "in_progress"),
activity("image-completed", "tool.completed", "image_view"),
]),
).toBeNull();
});

it("accepts providers that report terminal state on an update", () => {
expect(
latestWorkspaceMutationId([
activity("file-updated", "tool.updated", "file_change", "completed"),
]),
).toBe("file-updated");
});

it("scopes the same mutation to each preview resource", () => {
expect(workspaceMutationRefreshToken("file:/repo/README.md", "event-1")).not.toBe(
workspaceMutationRefreshToken("diff:/repo", "event-1"),
);
expect(workspaceMutationRefreshToken("file:/repo/README.md", null)).toBeNull();
});
});
65 changes: 65 additions & 0 deletions apps/web/src/hooks/useWorkspaceMutationRefresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { OrchestrationThreadActivity } from "@t3tools/contracts";
import { useEffect, useRef } from "react";

const WORKSPACE_MUTATION_ITEM_TYPES = new Set(["command_execution", "file_change"]);

function activityPayload(activity: OrchestrationThreadActivity): Record<string, unknown> | null {
return activity.payload !== null && typeof activity.payload === "object"
? (activity.payload as Record<string, unknown>)
: null;
}

/**
* The latest provider event after which files on disk may have changed.
* File tools are explicit; completed commands are included because a shell
* command can mutate the workspace without reporting the paths it touched.
*/
export function latestWorkspaceMutationId(
activities: ReadonlyArray<OrchestrationThreadActivity>,
): string | null {
for (let index = activities.length - 1; index >= 0; index -= 1) {
const activity = activities[index];
if (!activity) continue;
const payload = activityPayload(activity);
const terminalUpdate =
activity.kind === "tool.updated" &&
typeof payload?.status === "string" &&
payload.status !== "inProgress" &&
payload.status !== "in_progress";
if (activity.kind !== "tool.completed" && !terminalUpdate) continue;
const itemType = payload?.itemType;
if (typeof itemType === "string" && WORKSPACE_MUTATION_ITEM_TYPES.has(itemType)) {
return activity.id;
}
}
return null;
}

export function workspaceMutationRefreshToken(
resourceKey: string,
mutationId: string | null,
): string | null {
return mutationId === null ? null : `${resourceKey}\u0000${mutationId}`;
}

/**
* Refreshes once per mutation and resource. Disabled mutations stay pending,
* which lets an editable file catch up after its local save finishes.
*/
export function useWorkspaceMutationRefresh(input: {
readonly enabled?: boolean;
readonly mutationId: string | null;
readonly refresh: () => void;
readonly resourceKey: string;
}): void {
const { enabled = true, mutationId, refresh, resourceKey } = input;
const handledTokenRef = useRef<string | null>(null);

useEffect(() => {
if (!enabled) return;
const token = workspaceMutationRefreshToken(resourceKey, mutationId);
if (token === null || token === handledTokenRef.current) return;
handledTokenRef.current = token;
refresh();
}, [enabled, mutationId, refresh, resourceKey]);
}
Loading