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
75 changes: 71 additions & 4 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type ThreadId,
type TurnId,
type KeybindingCommand,
T3_PROJECT_FILE_NAME,
OrchestrationThreadActivity,
ProviderInteractionMode,
ProviderDriverKind,
Expand Down Expand Up @@ -144,6 +145,8 @@ import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
import { stackedThreadToast, toastManager } from "./ui/toast";
import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
import { type NewProjectScriptInput } from "./ProjectScriptsControl";
import { getProjectFileQueryData, setProjectFileQueryData } from "./files/projectFilesQueryState";
import { upsertT3ProjectFileScript } from "~/t3ProjectFileScripts";
import {
buildProjectScript,
commandForProjectScript,
Expand Down Expand Up @@ -1106,6 +1109,9 @@ function ChatViewContent(props: ChatViewProps) {
);
const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]);
const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false });
const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, {
reportFailure: false,
});
const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, {
reportFailure: false,
});
Expand Down Expand Up @@ -2799,6 +2805,36 @@ function ChatViewContent(props: ChatViewProps) {
},
[environmentId, updateProject, upsertKeybinding],
);
const shareProjectScript = useCallback(
async (input: {
readonly script: ProjectScript;
readonly previousScript?: ProjectScript;
}): Promise<void> => {
if (!activeProject) return;
const cwd = activeProject.workspaceRoot;
const existingContents =
getProjectFileQueryData(activeProject.environmentId, cwd, T3_PROJECT_FILE_NAME)?.contents ??
null;
const contents = upsertT3ProjectFileScript({
contents: existingContents,
script: input.script,
...(input.previousScript ? { previousScript: input.previousScript } : {}),
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Share skips unread t3.json

High Severity

Sharing a project script can overwrite the t3.json file. This happens because getProjectFileQueryData returns null if the file read query is pending or failed. upsertT3ProjectFileScript then interprets this null as an empty file, leading to a new, minimal t3.json being written that discards existing shared actions and settings.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d98df4f. Configure here.

const result = await writeProjectFile({
environmentId: activeProject.environmentId,
input: {
cwd,
relativePath: T3_PROJECT_FILE_NAME,
contents,
},
});
if (result._tag === "Failure") {
throw squashAtomCommandFailure(result);
}
setProjectFileQueryData(activeProject.environmentId, cwd, T3_PROJECT_FILE_NAME, contents);
},
[activeProject, writeProjectFile],
);
const saveProjectScript = useCallback(
async (input: NewProjectScriptInput): Promise<AtomCommandResult<void, unknown>> => {
if (!activeProject) {
Expand All @@ -2818,16 +2854,30 @@ function ChatViewContent(props: ChatViewProps) {
]
: [...activeProject.scripts, nextScript];

return persistProjectScripts({
const result = await persistProjectScripts({
projectId: activeProject.id,
projectCwd: activeProject.workspaceRoot,
previousScripts: activeProject.scripts,
nextScripts,
keybinding: input.keybinding,
keybindingCommand: commandForProjectScript(nextId),
});
if (result._tag === "Success" && input.shareWithProject) {
try {
await shareProjectScript({ script: nextScript });
} catch (error) {
toastManager.add(
stackedThreadToast({
type: "error",
title: "Action saved, but could not be shared",
description: error instanceof Error ? error.message : "Could not update t3.json.",
}),
);
}
}
return result;
},
[activeProject, persistProjectScripts],
[activeProject, persistProjectScripts, shareProjectScript],
);
const updateProjectScript = useCallback(
async (
Expand All @@ -2851,16 +2901,33 @@ function ChatViewContent(props: ChatViewProps) {
: script,
);

return persistProjectScripts({
const result = await persistProjectScripts({
projectId: activeProject.id,
projectCwd: activeProject.workspaceRoot,
previousScripts: activeProject.scripts,
nextScripts,
keybinding: input.keybinding,
keybindingCommand: commandForProjectScript(scriptId),
});
if (result._tag === "Success" && input.shareWithProject) {
try {
await shareProjectScript({
script: updatedScript,
previousScript: existingScript,
});
} catch (error) {
toastManager.add(
stackedThreadToast({
type: "error",
title: "Action saved, but could not be shared",
description: error instanceof Error ? error.message : "Could not update t3.json.",
}),
);
}
}
return result;
},
[activeProject, persistProjectScripts],
[activeProject, persistProjectScripts, shareProjectScript],
);
const deleteProjectScript = useCallback(
async (scriptId: string): Promise<AtomCommandResult<void, unknown>> => {
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/components/ProjectScriptsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ export interface NewProjectScriptInput {
command: string;
icon: ProjectScriptIcon;
runOnWorktreeCreate: boolean;
/** When true, also add this action to the repository's checked-in t3.json. */
shareWithProject: boolean;
keybinding: string | null;
/** Optional URL to open in the in-app preview when this script runs. */
previewUrl: string | null;
Expand Down Expand Up @@ -144,6 +146,7 @@ export default function ProjectScriptsControl({
const [icon, setIcon] = useState<ProjectScriptIcon>("play");
const [iconPickerOpen, setIconPickerOpen] = useState(false);
const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);
const [shareWithProject, setShareWithProject] = useState(false);
const [keybinding, setKeybinding] = useState("");
const [previewUrl, setPreviewUrl] = useState("");
const [autoOpenPreview, setAutoOpenPreview] = useState(false);
Expand Down Expand Up @@ -217,6 +220,7 @@ export default function ProjectScriptsControl({
command: trimmedCommand,
icon,
runOnWorktreeCreate,
shareWithProject,
keybinding: keybindingRule?.key ?? null,
previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null,
autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false,
Expand Down Expand Up @@ -247,6 +251,7 @@ export default function ProjectScriptsControl({
setIcon("play");
setIconPickerOpen(false);
setRunOnWorktreeCreate(false);
setShareWithProject(false);
setKeybinding("");
setPreviewUrl("");
setAutoOpenPreview(false);
Expand All @@ -261,6 +266,7 @@ export default function ProjectScriptsControl({
setIcon(script.icon);
setIconPickerOpen(false);
setRunOnWorktreeCreate(script.runOnWorktreeCreate);
setShareWithProject(false);
setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? "");
setPreviewUrl(script.previewUrl ?? "");
setAutoOpenPreview(script.autoOpenPreview ?? false);
Expand All @@ -281,6 +287,7 @@ export default function ProjectScriptsControl({
command: fileScript.command,
icon: fileScript.icon ?? "play",
runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,
shareWithProject: false,
keybinding: null,
previewUrl: fileScript.previewUrl ?? null,
autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,
Expand All @@ -296,6 +303,7 @@ export default function ProjectScriptsControl({
setIcon(payload.icon);
setIconPickerOpen(false);
setRunOnWorktreeCreate(payload.runOnWorktreeCreate);
setShareWithProject(false);
setKeybinding("");
setPreviewUrl(payload.previewUrl ?? "");
setAutoOpenPreview(payload.autoOpenPreview);
Expand Down Expand Up @@ -454,6 +462,7 @@ export default function ProjectScriptsControl({
setCommand("");
setIcon("play");
setRunOnWorktreeCreate(false);
setShareWithProject(false);
setKeybinding("");
setPreviewUrl("");
setAutoOpenPreview(false);
Expand Down Expand Up @@ -562,6 +571,18 @@ export default function ProjectScriptsControl({
onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))}
/>
</label>
<label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035]">
<span className="flex flex-col gap-0.5">
<span>Share with everyone on this project</span>
<span className="text-xs font-normal text-muted-foreground">
Adds this action to the repository&apos;s t3.json file.
</span>
</span>
<Switch
checked={shareWithProject}
onCheckedChange={(checked) => setShareWithProject(Boolean(checked))}
/>
</label>
<label
className={`flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035] ${
previewUrl.trim().length === 0 ? "opacity-60" : ""
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/components/files/projectFilesQueryState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ export function getOptimisticProjectFileQueryData(
return appAtomRegistry.get(optimisticFileAtom(environmentId, cwd, relativePath))?.data ?? null;
}

export function getProjectFileQueryData(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High files/projectFilesQueryState.ts:71

getProjectFileQueryData returns null whenever the AsyncResult has no value — including while the read is still in-flight or after a transient read failure. Callers treat null as "file does not exist" and proceed to create fresh contents and write them, so an existing t3.json can be overwritten and project settings/actions lost if the user saves before the initial read completes or after a transient read failure. The function conflates "no value yet" with "file does not exist" because it only checks Option.getOrNull(AsyncResult.value(result)) and ignores the AsyncResult state. Consider distinguishing pending/failed states from a confirmed empty result so callers don't write over a file they haven't successfully read.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/files/projectFilesQueryState.ts around line 71:

`getProjectFileQueryData` returns `null` whenever the `AsyncResult` has no value — including while the read is still in-flight or after a transient read failure. Callers treat `null` as "file does not exist" and proceed to create fresh contents and write them, so an existing `t3.json` can be overwritten and project settings/actions lost if the user saves before the initial read completes or after a transient read failure. The function conflates "no value yet" with "file does not exist" because it only checks `Option.getOrNull(AsyncResult.value(result))` and ignores the `AsyncResult` state. Consider distinguishing pending/failed states from a confirmed empty result so callers don't write over a file they haven't successfully read.

environmentId: EnvironmentId,
cwd: string,
relativePath: string,
): ProjectReadFileResult | null {
const optimistic = getOptimisticProjectFileQueryData(environmentId, cwd, relativePath);
if (optimistic !== null) return optimistic;
const result = appAtomRegistry.get(getProjectFileQueryAtom(environmentId, cwd, relativePath));
return Option.getOrNull(AsyncResult.value(result));
}

export function confirmProjectFileQueryData(
environmentId: EnvironmentId,
cwd: string,
Expand Down
100 changes: 100 additions & 0 deletions apps/web/src/t3ProjectFileScripts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vite-plus/test";

import { upsertT3ProjectFileScript } from "./t3ProjectFileScripts";

const script = {
id: "script-test",
name: "Test",
command: "vp test",
icon: "test" as const,
runOnWorktreeCreate: false,
autoOpenPreview: false,
};

describe("upsertT3ProjectFileScript", () => {
it("creates a project file for the first shared action", () => {
const result = JSON.parse(
upsertT3ProjectFileScript({
contents: null,
script,
}),
);

expect(result).toEqual({
$schema: "https://t3.codes/schema/t3.json",
scripts: [
{
name: "Test",
command: "vp test",
icon: "test",
runOnWorktreeCreate: false,
},
],
});
});

it("appends an action while preserving existing project settings", () => {
const result = JSON.parse(
upsertT3ProjectFileScript({
contents: JSON.stringify({
$schema: "https://t3.codes/schema/t3.json",
iconPath: "assets/logo.svg",
futureSetting: { enabled: true },
scripts: [{ name: "Dev", command: "vp dev" }],
}),
script,
}),
);

expect(result.iconPath).toBe("assets/logo.svg");
expect(result.futureSetting).toEqual({ enabled: true });
expect(result.scripts).toEqual([
{ name: "Dev", command: "vp dev" },
{
name: "Test",
command: "vp test",
icon: "test",
runOnWorktreeCreate: false,
},
]);
});

it("updates an edited shared action instead of duplicating it", () => {
const previousScript = { ...script, name: "Checks", command: "vp check" };
const result = JSON.parse(
upsertT3ProjectFileScript({
contents: JSON.stringify({
scripts: [{ name: "Checks", command: "vp check" }],
}),
previousScript,
script,
}),
);

expect(result.scripts).toHaveLength(1);
expect(result.scripts[0]).toMatchObject({ name: "Test", command: "vp test" });
});

it("rejects an invalid existing project file", () => {
expect(() =>
upsertT3ProjectFileScript({
contents: "{ invalid",
script,
}),
).toThrow("t3.json is invalid");
});

it("does not exceed the shared action limit", () => {
expect(() =>
upsertT3ProjectFileScript({
contents: JSON.stringify({
scripts: Array.from({ length: 50 }, (_, index) => ({
name: `Action ${index}`,
command: `echo ${index}`,
})),
}),
script,
}),
).toThrow("maximum of 50 shared actions");
});
});
Loading
Loading