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
15 changes: 7 additions & 8 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { isDraftThreadRoute } from "./features/threads/thread-route-state";
import {
isDraftThreadRoute,
type NewTaskDraftRouteParams,
} from "./features/threads/thread-route-state";
import { ComponentCatalogScreen } from "./features/development/ComponentCatalogScreen";
import { PullRequestGuideStopScreen } from "./features/pull-requests/guide/PullRequestGuideScreen";
import { PullRequestInlineCommentScreen } from "./features/pull-requests/PullRequestInlineCommentScreen";
Expand Down Expand Up @@ -393,18 +396,14 @@ function RootStackLayout(props: {
const draftRouteOnTop =
draftRoute !== undefined &&
props.state.routes.findLast((route) => route.name === "Thread") === draftRoute;
const draftParams = (
draftRoute?.params as { draft?: { draftId?: string | string[] } } | undefined
)?.draft;
const routeDraftId = Array.isArray(draftParams?.draftId)
? draftParams.draftId[0]
: draftParams?.draftId;
const draftParams = (draftRoute?.params as { draft?: NewTaskDraftRouteParams } | undefined)
?.draft;

return (
<NewTaskFlowProvider
active={draftRouteOnTop}
flowKey={draftRoute === undefined ? undefined : newTaskFlowKey(draftRoute)}
routeDraftId={routeDraftId}
routeDraft={draftParams}
>
<HardwareKeyboardCommandProvider pathname={pathname}>
<ShowcaseReadyMarker />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ vi.mock("../../state/use-model-option-memory", () => ({
withRememberedModelOptions: (value: unknown) => value,
}));
vi.mock("../../state/use-composer-drafts", () => ({
composerDraftsAtom: Atom.make({}),
composerDraftsLoadedAtom: Atom.make(true),
useComposerDraft: mocks.drafts,
getComposerDraftSnapshot: mocks.snapshot,
Expand All @@ -95,6 +96,11 @@ vi.mock("../../state/use-composer-drafts", () => ({
scheduleUnusedComposerAttachmentCleanup: vi.fn(),
isComposerDraftEmpty: (draft: ComposerDraft) =>
draft.text === "" && draft.attachments.length === 0,
isComposerDraftInUse: () => false,
findResumableNewTaskDraftKey: () => null,
}));
vi.mock("../../lib/rootNavigation", () => ({
rootNavigationRef: { getRootState: () => undefined },
}));

vi.mock("../../state/peerLinks", () => ({ useLinkedEnvironments: () => new Map() }));
Expand Down
65 changes: 42 additions & 23 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
import { scopedProjectKey } from "../../lib/scopedEntities";
import { useAtomValue } from "@effect/atom-react";
import { appAtomRegistry } from "../../state/atom-registry";
import { rootNavigationRef } from "../../lib/rootNavigation";
import { projectEnvironment } from "../../state/projects";
import { useLinkedEnvironments } from "../../state/peerLinks";
import { useEnvironmentQuery } from "../../state/query";
Expand All @@ -44,9 +45,12 @@ import {
appendComposerDraftAttachments,
type ComposerDraftInsertion,
type ComposerDraftWorkspaceSelection,
composerDraftsAtom,
composerDraftsLoadedAtom,
createNewTaskDraft,
findResumableNewTaskDraftKey,
getComposerDraftSnapshot,
isComposerDraftInUse,
isNewTaskDraftKey,
removeComposerDraftAttachment,
replaceComposerDraftAttachments,
Expand Down Expand Up @@ -92,15 +96,18 @@ import { useNewTaskProjectDefaults } from "./use-new-task-project-defaults";
import { resolveNewThreadRuntimeMode } from "./thread-settings-options";
import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation";
import {
boundNewTaskDraftKeys,
INACTIVE_FLOW_KEY,
type NewTaskFlowState,
newTaskFlowStateAtom,
readNewTaskFlowState,
resolveRouteDraft,
type RouteDraftResolution,
stampedEnvironmentProjectsLoading,
unsentDraftResumeDue,
updateNewTaskFlowState,
} from "./new-task-flow-state";
import type { NewTaskDraftRouteParams } from "./thread-route-state";
import { useEnvironmentShellReadiness } from "../../state/shell";
import { useConnectionsReady, useWorkspaceEnvironments } from "../../state/workspace";

Expand Down Expand Up @@ -214,7 +221,7 @@ export function NewTaskFlowProvider(
active?: boolean;
/** Identifies the draft route this flow serves; a new key starts a fresh flow. */
flowKey?: string;
routeDraftId?: string;
routeDraft?: NewTaskDraftRouteParams;
}>,
) {
const projects = useProjects();
Expand All @@ -234,12 +241,15 @@ export function NewTaskFlowProvider(
});

const flowKey = props.flowKey ?? INACTIVE_FLOW_KEY;
const routeDraftId = Array.isArray(props.routeDraft?.draftId)
? props.routeDraft.draftId[0]
: props.routeDraft?.draftId;
const storedFlowState = useAtomValue(newTaskFlowStateAtom(flowKey));
const draftsLoaded = useAtomValue(composerDraftsLoadedAtom);
// A draft route opened from a list row, a deep link, or restored state binds
// its draft here until the flow's first write stores the binding.
const routeDraft = useComposerDraft(
storedFlowState.draftKey === null ? (props.routeDraftId ?? null) : null,
storedFlowState.draftKey === null ? (routeDraftId ?? null) : null,
);
// Readiness of the environment the route's draft is stamped to; another
// environment's projects loading first says nothing about it.
Expand All @@ -264,7 +274,7 @@ export function NewTaskFlowProvider(
): RouteDraftResolution =>
resolveRouteDraft({
flow,
routeDraftId: props.routeDraftId,
routeDraftId,
draftsLoaded: loaded,
stamp,
projects,
Expand All @@ -286,9 +296,7 @@ export function NewTaskFlowProvider(
const resolution = resolveRoute(
stored,
appAtomRegistry.get(composerDraftsLoadedAtom),
props.routeDraftId === undefined
? undefined
: getComposerDraftSnapshot(props.routeDraftId).project,
routeDraftId === undefined ? undefined : getComposerDraftSnapshot(routeDraftId).project,
);
return resolution.kind === "open" ? resolution.flow : stored;
};
Expand Down Expand Up @@ -326,30 +334,42 @@ export function NewTaskFlowProvider(
const serverConfigs = useServerConfigs();
const { connectedEnvironments } = useRemoteConnectionStatus();
const selectedProjectDraftKey = selectedProject ? activeDraftKey : null;
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- creates the persistent draft in the new-task flow store once a project is selected, before the composer mounts
const resumeUnsentDraft = unsentDraftResumeDue({
route: props.routeDraft,
flow: flowState,
draftsLoaded,
selectedProject,
});
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- creates or resumes the persistent draft in the new-task flow store once a project is selected, before the composer mounts
useEffect(() => {
// A route that names a draft binds that draft, or returns to the picker.
if (
props.active === false ||
props.routeDraftId !== undefined ||
activeDraftKey !== null ||
selectedProject === null
) {
if (props.active === false || routeDraftId !== undefined || selectedProject === null) {
return;
}
// Creates the persistent draft once the project selection is ready; the key must exist before the composer mounts.
const project = { environmentId: selectedProject.environmentId, projectId: selectedProject.id };
if (activeDraftKey === null) {
updateNewTaskFlowState(flowKey, { draftKey: createNewTaskDraft(project) });
return;
}
if (!resumeUnsentDraft) return;
// Work the user started before drafts loaded wins over an older unsent draft.
const unsentDraftKey = isComposerDraftInUse(activeDraftKey)
? null
: findResumableNewTaskDraftKey(
appAtomRegistry.get(composerDraftsAtom),
project,
boundNewTaskDraftKeys(rootNavigationRef.getRootState()),
);
updateNewTaskFlowState(flowKey, {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
draftKey: createNewTaskDraft({
environmentId: selectedProject.environmentId,
projectId: selectedProject.id,
}),
unsentDraftChecked: true,
...(unsentDraftKey === null ? {} : { draftKey: unsentDraftKey }),
});
}, [activeDraftKey, flowKey, selectedProject, props.active, props.routeDraftId]);
}, [activeDraftKey, flowKey, selectedProject, props.active, resumeUnsentDraft, routeDraftId]);
const activePendingKey =
props.active === false
? null
: props.routeDraftId
? `draft-task:${props.routeDraftId}`
: routeDraftId
? `draft-task:${routeDraftId}`
: selectedProjectDraftKey
? `draft-task:${selectedProjectDraftKey}`
: null;
Expand Down Expand Up @@ -851,8 +871,7 @@ export function NewTaskFlowProvider(
providerGroups,
setProject,
getSelectedProjectKey: () => readFlow().projectKey,
missingRouteDraftId:
routeDraftResolution.kind === "missing" ? (props.routeDraftId ?? null) : null,
missingRouteDraftId: routeDraftResolution.kind === "missing" ? (routeDraftId ?? null) : null,
selectEnvironment,
switchEnvironment,
switchingToEnvironmentId,
Expand Down
50 changes: 50 additions & 0 deletions apps/mobile/src/features/threads/new-task-flow-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import { describe, expect, it } from "vite-plus/test";

import { appAtomRegistry } from "../../state/atom-registry";
import {
boundNewTaskDraftKeys,
INITIAL_NEW_TASK_FLOW_STATE,
newTaskFlowKey,
newTaskFlowStateAtom,
pruneNewTaskFlowStates,
readNewTaskFlowState,
resolveRouteDraft,
stampedEnvironmentProjectsLoading,
unsentDraftResumeDue,
updateNewTaskFlowState,
} from "./new-task-flow-state";

Expand Down Expand Up @@ -152,3 +154,51 @@ describe("stampedEnvironmentProjectsLoading", () => {
expect(stampedEnvironmentProjectsLoading({ ...settled, shellHasError: true })).toBe(false);
});
});

describe("boundNewTaskDraftKeys", () => {
it("holds a covered route's draft before its flow writes, so a plain route above skips it", () => {
const covered = draftRoute("route-covered", "new-task:covered");
const plain = { key: "route-plain", name: "Thread", params: { draft: {} } };
const state = { routes: [{ key: "home", name: "Home" }, covered, plain] };
pruneNewTaskFlowStates(state);
updateNewTaskFlowState(newTaskFlowKey(plain)!, { draftKey: "new-task:fresh" });

expect(boundNewTaskDraftKeys(state)).toEqual(new Set(["new-task:fresh", "new-task:covered"]));
});
});

describe("unsentDraftResumeDue", () => {
const routeProject = { environmentId: EnvironmentId.make("env"), id: ProjectId.make("route") };
const fallbackProject = { environmentId: EnvironmentId.make("env"), id: ProjectId.make("first") };
const flow = { ...INITIAL_NEW_TASK_FLOW_STATE, draftKey: "new-task:fresh" };
const due = (input: Partial<Parameters<typeof unsentDraftResumeDue>[0]>) =>
unsentDraftResumeDue({
route: { environmentId: "env", projectId: "route" },
flow,
draftsLoaded: true,
selectedProject: routeProject,
...input,
});

it("waits for drafts and the route's project, not the environment's fallback", () => {
expect(due({})).toBe(true);
expect(due({ draftsLoaded: false })).toBe(false);
expect(due({ selectedProject: fallbackProject })).toBe(false);
expect(due({ flow: { ...flow, unsentDraftChecked: true } })).toBe(false);
});

it("resumes for the project a picker route settles on", () => {
const picker = { route: {}, selectedProject: fallbackProject };
expect(due(picker)).toBe(false);
expect(due({ ...picker, flow: { ...flow, projectKey: "env:first" } })).toBe(true);
});

it("keeps a fresh draft for draft, branch, clone, and share routes", () => {
const route = { environmentId: "env", projectId: "route" };
expect(due({ route: { ...route, draftId: "new-task:a" } })).toBe(false);
expect(due({ route: { ...route, branch: "feature" } })).toBe(false);
expect(due({ route: { ...route, cloning: "1" } })).toBe(false);
expect(due({ route: { ...route, incomingShareId: "share" } })).toBe(false);
expect(due({ route: { ...route, worktreePath: "/repo/wt" } })).toBe(true);
});
});
64 changes: 64 additions & 0 deletions apps/mobile/src/features/threads/new-task-flow-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface NewTaskFlowState {
readonly draftKey: string | null;
readonly submitting: boolean;
readonly expandedProvider: string | null;
readonly unsentDraftChecked: boolean;
}

export const INITIAL_NEW_TASK_FLOW_STATE: NewTaskFlowState = {
Expand All @@ -38,6 +39,7 @@ export const INITIAL_NEW_TASK_FLOW_STATE: NewTaskFlowState = {
draftKey: null,
submitting: false,
expandedProvider: null,
unsentDraftChecked: false,
};

// Every draft route on the stack keeps its flow, not just the one the provider
Expand Down Expand Up @@ -66,6 +68,68 @@ export function updateNewTaskFlowState(flowKey: string, patch: Partial<NewTaskFl
});
}

/**
* Whether a plain draft route's flow should now resume its project's newest
* unsent draft: once per flow, after drafts load, and only for the route's
* project or, without one, the project the flow settled on. Draft, branch,
* clone, and share routes keep their fresh draft.
*/
export function unsentDraftResumeDue(input: {
readonly route: NewTaskDraftRouteParams | undefined;
readonly flow: NewTaskFlowState;
readonly draftsLoaded: boolean;
readonly selectedProject: {
readonly environmentId: EnvironmentId;
readonly id: ProjectId;
} | null;
}): boolean {
const { route, flow, selectedProject } = input;
if (
route === undefined ||
selectedProject === null ||
flow.draftKey === null ||
flow.unsentDraftChecked ||
!input.draftsLoaded
) {
return false;
}
const first = (value: string | string[] | undefined) => (Array.isArray(value) ? value[0] : value);
if (
first(route.draftId) !== undefined ||
first(route.incomingShareId) !== undefined ||
route.branch ||
first(route.cloning) === "1"
) {
return false;
}
const environmentId = first(route.environmentId);
const projectId = first(route.projectId);
if (environmentId && projectId) {
return selectedProject.environmentId === environmentId && selectedProject.id === projectId;
}
return flow.projectKey === scopedProjectKey(selectedProject.environmentId, selectedProject.id);
}

/**
* Draft keys held by draft routes on the stack: bound by a flow, or still only
* named by a route whose flow has not written yet.
*/
export function boundNewTaskDraftKeys(state: NavigationRoutes | undefined): ReadonlySet<string> {
const keys = new Set(
Object.values(appAtomRegistry.get(flowStatesAtom)).flatMap((flow) =>
flow.draftKey === null ? [] : [flow.draftKey],
),
);
for (const route of state?.routes ?? []) {
const draftId = isDraftThreadRoute(route)
? (route.params as { draft: NewTaskDraftRouteParams }).draft.draftId
: undefined;
const key = Array.isArray(draftId) ? draftId[0] : draftId;
if (key !== undefined) keys.add(key);
}
return keys;
}

/**
* What a draft route's `draftId` means for a flow that has not bound a draft
* yet. Drafts hydrate from disk and projects arrive with the shell snapshot,
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/state/composerDraftOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ export function startComposerDraftOperation(draftKey: string): {
};
}

export function hasComposerDraftOperations(draftKey: string): boolean {
return operationsByDraftKey.has(draftKey);
}

export function abortComposerDraftOperations(draftKey: string): void {
const operations = operationsByDraftKey.get(draftKey);
if (!operations) return;
Expand Down
Loading
Loading