Skip to content
Merged
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
1,362 changes: 705 additions & 657 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

66 changes: 62 additions & 4 deletions src/components/Project/useRenameProjectAction.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { DialogProvider } from "@/providers/DialogProvider/DialogProvider";
import { getProject } from "@/services/projects/projectsService";
import type { Project } from "@/services/projects/types";
import { useUpdateProject } from "@/services/projects/useProjects";

Expand All @@ -28,6 +29,10 @@ vi.mock("@/services/projects/useProjects", () => ({
useUpdateProject: vi.fn(),
}));

vi.mock("@/services/projects/projectsService", () => ({
getProject: vi.fn(),
}));

vi.mock("@/hooks/useToastNotification", () => ({
default: () => notify,
}));
Expand Down Expand Up @@ -55,10 +60,14 @@ function Subject({ subject }: { subject: Project }) {
return <button onClick={() => void rename()}>Rename project</button>;
}

async function openRename(overrides: Partial<Project> = {}) {
async function openRename(
overrides: Partial<Project> = {},
latest: Partial<Project> = overrides,
) {
vi.mocked(useUpdateProject).mockReturnValue({
mutate,
} as unknown as ReturnType<typeof useUpdateProject>);
vi.mocked(getProject).mockResolvedValue({ ...project, ...latest });

const user = userEvent.setup();
render(
Expand All @@ -70,12 +79,22 @@ async function openRename(overrides: Partial<Project> = {}) {
return user;
}

async function renameTo(name: string, overrides: Partial<Project> = {}) {
const user = await openRename(overrides);

async function submitName(
user: ReturnType<typeof userEvent.setup>,
name: string,
) {
await user.clear(screen.getByLabelText("Name"));
await user.type(screen.getByLabelText("Name"), name);
await user.click(screen.getByRole("button", { name: "Rename" }));
}

async function renameTo(
name: string,
overrides: Partial<Project> = {},
latest: Partial<Project> = overrides,
) {
const user = await openRename(overrides, latest);
await submitName(user, name);

await waitFor(() => expect(mutate).toHaveBeenCalled());
}
Expand Down Expand Up @@ -117,6 +136,45 @@ describe("useRenameProjectAction", () => {
);
});

/** A stale copy would drop keys written since, like the shared folder id. */
it("keeps metadata written since the project was loaded", async () => {
await renameTo(
"Churn model v2",
{ metadata: { provisionalName: true } },
{
metadata: { provisionalName: true, tangentSharedFolderId: "folder-1" },
},
);

expect(getProject).toHaveBeenCalledWith("project-1");
expect(mutate).toHaveBeenCalledWith(
{
id: "project-1",
input: {
name: "Churn model v2",
metadata: { tangentSharedFolderId: "folder-1" },
},
},
expect.anything(),
);
});

it("leaves the project alone when it cannot be re-read", async () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
const user = await openRename();
vi.mocked(getProject).mockRejectedValue(new Error("down"));

await submitName(user, "Churn model v2");

await waitFor(() =>
expect(notify).toHaveBeenCalledWith("Failed to update project", "error"),
);
expect(mutate).not.toHaveBeenCalled();
consoleError.mockRestore();
});

it("says so once the rename lands", async () => {
await renameTo("Churn model v2");

Expand Down
13 changes: 12 additions & 1 deletion src/components/Project/useRenameProjectAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import useToastNotification from "@/hooks/useToastNotification";
import { useAnalytics } from "@/providers/AnalyticsProvider";
import { useDialog } from "@/providers/DialogProvider/hooks/useDialog";
import { convertCancelErrorTo } from "@/providers/DialogProvider/utils";
import { getProject } from "@/services/projects/projectsService";
import { withoutProvisionalName } from "@/services/projects/provisionalName";
import type { Project } from "@/services/projects/types";
import { useUpdateProject } from "@/services/projects/useProjects";
Expand Down Expand Up @@ -35,14 +36,24 @@ export function useRenameProjectAction(project: Project): () => Promise<void> {

if (!name) return;

// The PATCH replaces the whole metadata object, and the cached copy may
// predate keys written since (such as Tangent's shared folder id).
const latest = await getProject(project.id).catch((error: unknown) => {
console.error("Failed to read the project before renaming it", error);
notify("Failed to update project", "error");
return undefined;
});

if (!latest) return;

updateProject.mutate(
{
id: project.id,
input: {
name,
// A name someone typed is a deliberate one, so the agent stops
// offering to replace it.
metadata: withoutProvisionalName(project.metadata),
metadata: withoutProvisionalName(latest.metadata),
},
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import useToastNotification from "@/hooks/useToastNotification";
import { useBackfillSessionTags } from "@/routes/v2/pages/Tangent/hooks/useBackfillSessionTags";
import { usePrepareProjectArrival } from "@/routes/v2/pages/Tangent/hooks/usePrepareProjectArrival";
import { useProjectSessions } from "@/routes/v2/pages/Tangent/hooks/useProjectSessions";
import { useProjectSharedFolder } from "@/routes/v2/pages/Tangent/hooks/useProjectSharedFolder";
import { useRemoteEnvAuthToken } from "@/routes/v2/pages/Tangent/hooks/useRemoteEnvAuthToken";
import { useTangentBaseUrl } from "@/routes/v2/pages/Tangent/hooks/useTangentBaseUrl";
import { TangentProjectStore } from "@/routes/v2/pages/Tangent/store/TangentProjectStore";
Expand Down Expand Up @@ -41,6 +42,7 @@ export function TangentProjectProvider({
detachSession,
renameSession,
} = useProjectSessions(projectId);
const ensureSharedFolderId = useProjectSharedFolder(projectId);
const [store] = useState(() => new TangentProjectStore(projectId));
const storage = usePipelineStorage();
const queryClient = useQueryClient();
Expand All @@ -61,6 +63,7 @@ export function TangentProjectProvider({
if (!session || session.name || !name) return;
await renameSession(session.resourceId, name);
},
ensureSharedFolderId,
notify,
projectInstructions: instructions,
});
Expand All @@ -71,6 +74,7 @@ export function TangentProjectProvider({
detachSession,
renameSession,
sessions,
ensureSharedFolderId,
notify,
instructions,
]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAiProviderSettings } from "@/hooks/useAiProviderSettings";
import { createNewPipeline } from "@/routes/v2/pages/Editor/components/EditorMenuBar/components/fileMenu.actions";
import type { TangentProjectStore } from "@/routes/v2/pages/Tangent/store/TangentProjectStore";
import { getProject } from "@/services/projects/projectsService";
import { startingSessionMetadata } from "@/services/projects/startingSession";
import type {
Project,
Expand Down Expand Up @@ -37,6 +38,10 @@ vi.mock("@/services/projects/useProjects", () => ({
useUpdateProject: () => ({ mutateAsync: updateProject }),
}));

vi.mock("@/services/projects/projectsService", () => ({
getProject: vi.fn(),
}));

vi.mock("@/services/projects/useProjectResources", () => ({
useProjectResources: vi.fn(),
useCreateProjectResource: () => ({ mutateAsync: createResource }),
Expand Down Expand Up @@ -113,6 +118,7 @@ function given({
vi.mocked(useProject).mockReturnValue({
data: { ...project, ...projectOverrides },
} as unknown as ReturnType<typeof useProject>);
vi.mocked(getProject).mockResolvedValue({ ...project, ...projectOverrides });
vi.mocked(useProjectResources).mockReturnValue({
data: { items: documents },
} as unknown as ReturnType<typeof useProjectResources>);
Expand Down Expand Up @@ -335,6 +341,31 @@ describe("usePrepareProjectArrival", () => {
);
});

/** Starting the session saves the project's shared folder onto it. */
it("keeps what starting the session wrote when it clears the ask", async () => {
given({
projectOverrides: {
metadata: startingSessionMetadata({ prompt: "Fix run 7" }),
},
});
vi.mocked(getProject).mockResolvedValue({
...project,
metadata: {
...startingSessionMetadata({ prompt: "Fix run 7" }),
tangentSharedFolderId: "folder-1",
},
});

prepare(makeStore(), 2);

await waitFor(() =>
expect(updateProject).toHaveBeenCalledWith({
id: "project-1",
input: { metadata: { tangentSharedFolderId: "folder-1" } },
}),
);
});

/**
* An ask left on the project and never arrived for would otherwise open
* whichever session is started there next, days later or by someone else.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
pipelineFileTarget,
remoteResourceTarget,
} from "@/services/projects/pipelineResource";
import { getProject } from "@/services/projects/projectsService";
import { MAX_PAGE_SIZE } from "@/services/projects/queryDefaults";
import {
describeResource,
Expand Down Expand Up @@ -124,12 +125,15 @@ export function usePrepareProjectArrival(
// it, is housekeeping that stops the next arrival repeating it. The
// session has already started, so a refusal here must not cost the
// caller the pipeline and the workarea they came for — it is left for
// the next arrival to try again.
// the next arrival to try again. The metadata is re-read because
// starting the session may have written to it, and the PATCH replaces
// the whole object.
if (waiting) {
try {
const latest = await getProject(projectId);
await updateProject({
id: projectId,
input: { metadata: withoutStartingSession(project?.metadata) },
input: { metadata: withoutStartingSession(latest.metadata) },
});
} catch (error) {
console.error(
Expand Down
Loading
Loading