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
18 changes: 13 additions & 5 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,11 +281,15 @@ export function NewTaskDraftScreen(props: {
),
);
// A route naming a draft that is gone, or whose project is gone, also
// returns to the picker.
const isRouteDraftMissing =
props.draftId !== undefined && flow.missingRouteDraftId === props.draftId;
// returns to the picker; a draft that survives goes along with it.
const unopenableRouteDraftKind =
props.draftId !== undefined && flow.unopenableRouteDraft?.draftId === props.draftId
? flow.unopenableRouteDraft.kind
: null;
const pickerDraftId = unopenableRouteDraftKind === "pickProject" ? props.draftId : undefined;
const isProjectPickerReturnActive =
(isReturningToProjectPicker && !requestedInitialProjectAvailable) || isRouteDraftMissing;
(isReturningToProjectPicker && !requestedInitialProjectAvailable) ||
unopenableRouteDraftKind !== null;
const isIncomingShareAwaitingServerConfig = Boolean(
incomingShare?.attachments.some((attachment) => attachment.type === "file") &&
selectedEnvironmentServerConfig === null,
Expand Down Expand Up @@ -388,14 +392,18 @@ export function NewTaskDraftScreen(props: {
// Wait for usePreventRemove to disable before replacing, or the transfer guard swallows the fallback.
const frame = requestAnimationFrame(() => {
navigation.dispatch(
StackActions.replace("NewTaskProject", { incomingShareId: props.incomingShareId }),
StackActions.replace("NewTaskProject", {
incomingShareId: props.incomingShareId,
draftId: pickerDraftId,
}),
);
});
return () => cancelAnimationFrame(frame);
}, [
isProjectPickerReturnActive,
isReturningToProjectPicker,
navigation,
pickerDraftId,
props.incomingShareId,
requestedInitialProjectAvailable,
]);
Expand Down
25 changes: 24 additions & 1 deletion apps/mobile/src/features/threads/NewTaskRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { ProjectFavicon } from "../../components/ProjectFavicon";
import { useProjects, useServerConfigs } from "../../state/entities";
import { projectEnvironment } from "../../state/projects";
import { useAtomCommand } from "../../state/use-atom-command";
import { retargetSavedNewTaskDraft } from "../../state/use-composer-drafts";
import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
import type { WorkspaceState } from "../../state/workspaceModel";
import { useWorkspaceState } from "../../state/workspace";
Expand All @@ -44,6 +45,8 @@ import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspace

type NewTaskRouteParams = {
readonly incomingShareId?: string | string[];
/** An existing draft whose project is gone; the chosen project takes it over. */
readonly draftId?: string | string[];
readonly prompt?: string | string[];
readonly repo?: string | string[];
readonly pr?: string | string[];
Expand Down Expand Up @@ -164,6 +167,9 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
? route.params.incomingShareId[0]
: route.params?.incomingShareId;
const incomingShare = routeShareId ? getShare(routeShareId) : null;
const routeDraftId = Array.isArray(route.params?.draftId)
? route.params.draftId[0]
: route.params?.draftId;
const incomingShareSubtitle = incomingShare
? incomingShare.attachments.length === 0
? "Choose a project for what you shared"
Expand Down Expand Up @@ -228,17 +234,34 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
: navigation.getParent();
const rootState = root?.getState();
const threadRoute = rootState?.routes[rootState.index - 1];
if (isDraftThreadRoute(threadRoute)) {
if (routeDraftId === undefined && isDraftThreadRoute(threadRoute)) {
setProject(project);
root?.goBack();
return;
}
// Restamp only on choice, so backing out leaves the draft as it was.
const retargeted =
routeDraftId === undefined
? false
: await retargetSavedNewTaskDraft(routeDraftId, {
environmentId: project.environmentId,
projectId: project.id,
}).then(
(found) => found,
() => null,
);
if (retargeted === null) {
Dialog.alert("Could not change project", "Saved drafts could not be loaded. Try again.");
return;
}
const draftId = retargeted ? routeDraftId : undefined;
selectDetail({
name: "Thread",
params: {
draft: {
environmentId: String(project.environmentId),
projectId: String(project.id),
draftId,
incomingShareId: incomingShare?.id,
},
},
Expand Down
18 changes: 12 additions & 6 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,14 @@ type NewTaskFlowContextValue = {
/** The flow's project, read when a continuation resumes after an await. */
readonly getSelectedProjectKey: () => string | null;
/**
* The draft id of the route this flow serves when that draft, or its
* project, is gone. Only the topmost draft route is served, so a screen
* compares this with its own draft id.
* The route this flow serves when its draft cannot open: the draft is gone
* (`missing`) or only its project is (`pickProject`). Only the topmost draft
* route is served, so a screen compares `draftId` with its own draft id.
*/
readonly missingRouteDraftId: string | null;
readonly unopenableRouteDraft: {
readonly draftId: string;
readonly kind: "missing" | "pickProject";
} | null;
readonly selectEnvironment: (environmentId: EnvironmentId) => void;
readonly switchEnvironment: (environmentId: EnvironmentId) => Promise<boolean>;
readonly switchingToEnvironmentId: EnvironmentId | null;
Expand Down Expand Up @@ -851,8 +854,11 @@ export function NewTaskFlowProvider(
providerGroups,
setProject,
getSelectedProjectKey: () => readFlow().projectKey,
missingRouteDraftId:
routeDraftResolution.kind === "missing" ? (props.routeDraftId ?? null) : null,
unopenableRouteDraft:
props.routeDraftId !== undefined &&
(routeDraftResolution.kind === "missing" || routeDraftResolution.kind === "pickProject")
? { draftId: props.routeDraftId, kind: routeDraftResolution.kind }
: null,
selectEnvironment,
switchEnvironment,
switchingToEnvironmentId,
Expand Down
7 changes: 5 additions & 2 deletions apps/mobile/src/features/threads/new-task-flow-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,14 @@ describe("resolveRouteDraft", () => {
).toMatchObject({ kind: "open", flow: { environmentId: environmentB } });
});

it("reports a draft that is gone or whose project is gone", () => {
it("reports a draft that is gone", () => {
expect(resolveRouteDraft({ ...base, stamp: undefined })).toEqual({ kind: "missing" });
});

it("asks for a project for the same draft when its project is gone", () => {
expect(
resolveRouteDraft({ ...base, projects: [{ environmentId, id: ProjectId.make("other") }] }),
).toEqual({ kind: "missing" });
).toEqual({ kind: "pickProject" });
});
});

Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/features/threads/new-task-flow-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ export type RouteDraftResolution =
| { readonly kind: "none" }
| { readonly kind: "pending" }
| { readonly kind: "missing" }
/** The draft survives but its project is gone: pick another for it. */
| { readonly kind: "pickProject" }
| { readonly kind: "open"; readonly flow: NewTaskFlowState };

export function resolveRouteDraft(input: {
Expand All @@ -101,7 +103,8 @@ export function resolveRouteDraft(input: {
const loaded = input.projects.some(
(project) => project.environmentId === stamp.environmentId && project.id === stamp.projectId,
);
if (!loaded) return input.stampedEnvironmentLoading ? { kind: "pending" } : { kind: "missing" };
if (!loaded)
return input.stampedEnvironmentLoading ? { kind: "pending" } : { kind: "pickProject" };
return {
kind: "open",
flow: {
Expand Down
25 changes: 25 additions & 0 deletions apps/mobile/src/state/use-composer-drafts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ import {
restoreComposerDraftSnapshotState,
restoreCloudComposerDrafts,
retargetNewTaskDraft,
retargetSavedNewTaskDraft,
setComposerDraftText,
insertComposerDraftContext,
insertComposerDraftText,
Expand Down Expand Up @@ -1933,6 +1934,30 @@ describe("mobile composer drafts", () => {
expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), to)).toEqual([key]);
});

it("retargets a saved new-task draft only after drafts load", async () => {
const from = {
environmentId: EnvironmentId.make("environment-1"),
projectId: ProjectId.make("project-1"),
};
const to = {
environmentId: EnvironmentId.make("environment-1"),
projectId: ProjectId.make("project-2"),
};
const key = createNewTaskDraft(from);
setComposerDraftText(key, "kept through a cold start");
await flushComposerDrafts();
appAtomRegistry.set(composerDraftsAtom, {});
resetComposerDraftsLoadState();

await expect(retargetSavedNewTaskDraft(key, to)).resolves.toBe(true);
expect(getComposerDraftSnapshot(key)).toMatchObject({
text: "kept through a cold start",
project: to,
});
await expect(retargetSavedNewTaskDraft(`${key}-gone`, to)).resolves.toBe(false);
expect(appAtomRegistry.get(composerDraftsAtom)[`${key}-gone`]).toBeUndefined();
});

it("merges persisted option memory without replacing newer choices", async () => {
composerDraftFileMocks.setDocument({
schemaVersion: 1,
Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/src/state/use-composer-drafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1999,6 +1999,17 @@ export function retargetNewTaskDraft(
});
}

/** Retargets a saved draft once drafts load; an absent one is not created. */
export async function retargetSavedNewTaskDraft(
draftKey: string,
project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId },
): Promise<boolean> {
await waitForComposerDraftsLoaded();
if (appAtomRegistry.get(composerDraftsAtom)[draftKey] === undefined) return false;
retargetNewTaskDraft(draftKey, project);
return true;
}

export function findNewTaskDraftKeys(
drafts: Readonly<Record<string, ComposerDraft>>,
project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId },
Expand Down
Loading