diff --git a/apps/mobile/app/(app)/[workspace]/_layout.tsx b/apps/mobile/app/(app)/[workspace]/_layout.tsx
index d98c2dd72..0f4214620 100644
--- a/apps/mobile/app/(app)/[workspace]/_layout.tsx
+++ b/apps/mobile/app/(app)/[workspace]/_layout.tsx
@@ -14,6 +14,7 @@ import { usePresenceRealtime } from "@/data/realtime/use-presence-realtime";
import { useWorkspacePresencePrefetch } from "@/lib/use-workspace-presence-prefetch";
import { ModalCloseButton } from "@/components/ui/modal-close-button";
import { useNewIssueDraftResetOnWorkspaceChange } from "@/data/stores/new-issue-draft-store";
+import { useNewProjectDraftResetOnWorkspaceChange } from "@/data/stores/new-project-draft-store";
import { useChatSessionPickerResetOnWorkspaceChange } from "@/data/stores/chat-session-picker-store";
/**
@@ -107,6 +108,7 @@ export default function WorkspaceLayout() {
// changes — a draft picked under workspace A (assignee id, draft
// session id, etc.) is invalid in workspace B and must not leak.
useNewIssueDraftResetOnWorkspaceChange(matched?.id ?? null);
+ useNewProjectDraftResetOnWorkspaceChange(matched?.id ?? null);
useChatSessionPickerResetOnWorkspaceChange(matched?.id ?? null);
// Wait for the workspaces list before deciding membership — otherwise a
@@ -235,6 +237,16 @@ export default function WorkspaceLayout() {
name="new-issue-picker/due-date"
options={SHEET_OPTIONS}
/>
+ {/* New-project draft formSheet pickers — same pattern as
+ new-issue-picker/*. Stacked on top of `project/new` (a modal). */}
+
+
{/* Shared filter sheet for My Issues and the workspace Issues page —
chooses the right view-store via `?scope=my|all` URL param. */}
diff --git a/apps/mobile/app/(app)/[workspace]/new-project-picker/priority.tsx b/apps/mobile/app/(app)/[workspace]/new-project-picker/priority.tsx
new file mode 100644
index 000000000..972eb73c0
--- /dev/null
+++ b/apps/mobile/app/(app)/[workspace]/new-project-picker/priority.tsx
@@ -0,0 +1,21 @@
+/**
+ * Priority picker route for the in-progress new-project draft. See ./status.tsx.
+ */
+import { router } from "expo-router";
+import { ProjectPriorityPickerBody } from "@/components/project/pickers/project-priority-picker-body";
+import { useNewProjectDraftStore } from "@/data/stores/new-project-draft-store";
+
+export default function NewProjectPriorityPickerRoute() {
+ const priority = useNewProjectDraftStore((s) => s.priority);
+ const setPriority = useNewProjectDraftStore((s) => s.setPriority);
+
+ return (
+ {
+ setPriority(next);
+ router.back();
+ }}
+ />
+ );
+}
diff --git a/apps/mobile/app/(app)/[workspace]/new-project-picker/status.tsx b/apps/mobile/app/(app)/[workspace]/new-project-picker/status.tsx
new file mode 100644
index 000000000..4d1e9d32a
--- /dev/null
+++ b/apps/mobile/app/(app)/[workspace]/new-project-picker/status.tsx
@@ -0,0 +1,24 @@
+/**
+ * Status picker route for the in-progress new-project draft. Reads/writes
+ * `useNewProjectDraftStore` — the project/new.tsx modal owns the draft and
+ * reads from the same store. See ../project/new.tsx for the lifecycle, and
+ * ../new-issue-picker/status.tsx for the mirror pattern.
+ */
+import { router } from "expo-router";
+import { ProjectStatusPickerBody } from "@/components/project/pickers/project-status-picker-body";
+import { useNewProjectDraftStore } from "@/data/stores/new-project-draft-store";
+
+export default function NewProjectStatusPickerRoute() {
+ const status = useNewProjectDraftStore((s) => s.status);
+ const setStatus = useNewProjectDraftStore((s) => s.setStatus);
+
+ return (
+ {
+ setStatus(next);
+ router.back();
+ }}
+ />
+ );
+}
diff --git a/apps/mobile/app/(app)/[workspace]/project/new.tsx b/apps/mobile/app/(app)/[workspace]/project/new.tsx
index 01abb24e6..a5d745cb3 100644
--- a/apps/mobile/app/(app)/[workspace]/project/new.tsx
+++ b/apps/mobile/app/(app)/[workspace]/project/new.tsx
@@ -8,6 +8,10 @@
* project from a "I need to track this stream of work" intent and figure
* out who's leading it later. The picker lives on the detail screen.
*
+ * Status / priority cross-route through `useNewProjectDraftStore` so the
+ * formSheet picker routes can read/write them — same pattern as
+ * new-issue.tsx + new-issue-picker/* (see new-project-draft-store.ts).
+ *
* On success: dismiss modal → navigate to the new project's detail page so
* the user can immediately add a lead / attach issues / configure properties.
*/
@@ -16,7 +20,6 @@ import {
Alert,
InteractionManager,
KeyboardAvoidingView,
- Modal,
Platform,
Pressable,
ScrollView,
@@ -24,7 +27,6 @@ import {
View,
} from "react-native";
import { Stack, router } from "expo-router";
-import type { ProjectPriority, ProjectStatus } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { AutosizeTextArea } from "@/components/ui/autosize-textarea";
import {
@@ -33,15 +35,25 @@ import {
} from "@/components/ui/input-tokens";
import { ProjectStatusIcon } from "@/components/ui/project-status-icon";
import { ProjectPriorityIcon } from "@/components/ui/project-priority-icon";
-import { ProjectStatusPickerBody } from "@/components/project/pickers/project-status-picker-body";
-import { ProjectPriorityPickerBody } from "@/components/project/pickers/project-priority-picker-body";
import {
projectPriorityLabel,
projectStatusLabel,
} from "@/lib/project-status";
import { useCreateProject } from "@/data/mutations/projects";
+import { useNewProjectDraftStore } from "@/data/stores/new-project-draft-store";
import { useWorkspaceStore } from "@/data/workspace-store";
+/**
+ * Typed map of new-project picker route pathnames. Keeps `router.push` calls
+ * compile-checked rather than depending on free-form template strings —
+ * same approach as `create-form-attribute-row.tsx`.
+ */
+type NewProjectPickerField = "status" | "priority";
+const NEW_PROJECT_PICKER_PATHNAMES = {
+ status: "/[workspace]/new-project-picker/status",
+ priority: "/[workspace]/new-project-picker/priority",
+} as const satisfies Record;
+
export default function NewProject() {
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const create = useCreateProject();
@@ -49,11 +61,9 @@ export default function NewProject() {
const [title, setTitle] = useState("");
const [icon, setIcon] = useState("");
const [description, setDescription] = useState("");
- const [status, setStatus] = useState("planned");
- const [priority, setPriority] = useState("none");
-
- const [statusOpen, setStatusOpen] = useState(false);
- const [priorityOpen, setPriorityOpen] = useState(false);
+ const status = useNewProjectDraftStore((s) => s.status);
+ const priority = useNewProjectDraftStore((s) => s.priority);
+ const resetDraft = useNewProjectDraftStore((s) => s.reset);
const dirty =
title.length > 0 ||
@@ -64,8 +74,20 @@ export default function NewProject() {
const canCreate = title.trim().length > 0 && !create.isPending;
+ const openPicker = useCallback(
+ (field: NewProjectPickerField) => {
+ if (!wsSlug) return;
+ router.push({
+ pathname: NEW_PROJECT_PICKER_PATHNAMES[field],
+ params: { workspace: wsSlug },
+ });
+ },
+ [wsSlug],
+ );
+
const onCancel = useCallback(() => {
if (!dirty) {
+ resetDraft();
router.back();
return;
}
@@ -77,11 +99,14 @@ export default function NewProject() {
{
text: "Discard",
style: "destructive",
- onPress: () => router.back(),
+ onPress: () => {
+ resetDraft();
+ router.back();
+ },
},
],
);
- }, [dirty]);
+ }, [dirty, resetDraft]);
const onCreate = useCallback(() => {
if (!canCreate) return;
@@ -95,6 +120,7 @@ export default function NewProject() {
},
{
onSuccess: (project) => {
+ resetDraft();
router.back();
// Wait for the modal dismiss animation to finish before pushing
// the detail screen. `InteractionManager` resolves once iOS
@@ -113,7 +139,17 @@ export default function NewProject() {
},
},
);
- }, [canCreate, create, title, description, icon, status, priority, wsSlug]);
+ }, [
+ canCreate,
+ create,
+ title,
+ description,
+ icon,
+ status,
+ priority,
+ wsSlug,
+ resetDraft,
+ ]);
const headerLeft = useCallback(() => {
return (
@@ -186,7 +222,7 @@ export default function NewProject() {
setStatusOpen(true)}
+ onPress={() => openPicker("status")}
className="flex-row items-center gap-2 bg-secondary/50 rounded-md px-3 py-2.5"
>
@@ -199,7 +235,7 @@ export default function NewProject() {
setPriorityOpen(true)}
+ onPress={() => openPicker("priority")}
className="flex-row items-center gap-2 bg-secondary/50 rounded-md px-3 py-2.5"
>
@@ -212,70 +248,10 @@ export default function NewProject() {
-
- setStatusOpen(false)}>
- {
- setStatus(next);
- setStatusOpen(false);
- }}
- />
-
- setPriorityOpen(false)}
- >
- {
- setPriority(next);
- setPriorityOpen(false);
- }}
- />
-
>
);
}
-/**
- * Inline modal shell for the new-project draft pickers. The host screen is
- * already a presentation="modal", and routes can't read draft local state
- * (the project doesn't exist yet, nothing in the cache to push the picker
- * to). So we keep a lightweight centered card here rather than wire a
- * separate draft store for a single transient form — matches the carve-out
- * in apps/mobile/CLAUDE.md "modal container selection" for short fixed
- * picker lists with no neighbour pickers to be consistent with.
- */
-function DraftPickerModal({
- visible,
- onClose,
- children,
-}: {
- visible: boolean;
- onClose: () => void;
- children: React.ReactNode;
-}) {
- return (
-
-
-
- {}} className="w-full max-w-sm">
-
- {children}
-
-
-
-
-
- );
-}
-
function Field({
label,
children,
diff --git a/apps/mobile/data/stores/new-project-draft-store.ts b/apps/mobile/data/stores/new-project-draft-store.ts
new file mode 100644
index 000000000..13020333f
--- /dev/null
+++ b/apps/mobile/data/stores/new-project-draft-store.ts
@@ -0,0 +1,62 @@
+/**
+ * Draft state for the New Project modal (`app/(app)/[workspace]/project/new.tsx`).
+ *
+ * Mirrors `new-issue-draft-store.ts` — same rationale: the formSheet picker
+ * routes (`new-project-picker/status.tsx`, `new-project-picker/priority.tsx`)
+ * live in a separate Stack screen and have no React parent-child relationship
+ * with the new-project modal. They need a way to read the current draft value
+ * and write the new selection back without prop-drilling through the router.
+ * A small Zustand store is the minimum-viable cross-screen channel — and the
+ * same pattern as new-issue.
+ *
+ * Lifecycle: `reset()` runs from `project/new.tsx` when the user dismisses
+ * the modal (either submit succeeds or they cancel) so the next open starts
+ * clean. Title / description / icon stay local useState because they're
+ * controlled inputs that never leave the new-project screen; only the
+ * attribute-chip values cross routes.
+ *
+ * Workspace lifecycle: workspace-scoped — reset is wired in
+ * `app/(app)/[workspace]/_layout.tsx` via
+ * `useNewProjectDraftResetOnWorkspaceChange()`, the only caller.
+ */
+import { useEffect, useRef } from "react";
+import { create } from "zustand";
+import type { ProjectPriority, ProjectStatus } from "@multica/core/types";
+
+interface NewProjectDraftState {
+ status: ProjectStatus;
+ priority: ProjectPriority;
+ setStatus: (next: ProjectStatus) => void;
+ setPriority: (next: ProjectPriority) => void;
+ reset: () => void;
+}
+
+const INITIAL: Pick = {
+ status: "planned",
+ priority: "none",
+};
+
+export const useNewProjectDraftStore = create((set) => ({
+ ...INITIAL,
+ setStatus: (next) => set({ status: next }),
+ setPriority: (next) => set({ priority: next }),
+ reset: () => set({ ...INITIAL }),
+}));
+
+/**
+ * Clears the new-project draft store whenever the active workspace id
+ * changes. The previous draft is invalid in a different workspace (we may
+ * later add fields like `lead` whose id only resolves in the seeded
+ * workspace). The `useRef` gate ensures the first mount is a no-op — we
+ * only fire `reset()` when the id actually changes, not on the initial
+ * render where `prev === current`.
+ */
+export function useNewProjectDraftResetOnWorkspaceChange(wsId: string | null) {
+ const prevRef = useRef(wsId);
+ useEffect(() => {
+ if (prevRef.current !== wsId) {
+ useNewProjectDraftStore.getState().reset();
+ prevRef.current = wsId;
+ }
+ }, [wsId]);
+}