diff --git a/apps/mobile/app/(app)/[workspace]/more/projects.tsx b/apps/mobile/app/(app)/[workspace]/more/projects.tsx
index a78b8ae94..8622b5ccf 100644
--- a/apps/mobile/app/(app)/[workspace]/more/projects.tsx
+++ b/apps/mobile/app/(app)/[workspace]/more/projects.tsx
@@ -1,17 +1,147 @@
-import { View } from "react-native";
-import { Text } from "@/components/ui/text";
-
/**
- * Projects browse page (placeholder). Read-only list of workspace projects,
- * filled in a later phase. Title comes from Stack.Screen options in
- * `[workspace]/_layout.tsx`.
+ * Projects browse page. Flat FlatList over the workspace's projects.
+ *
+ * Title and `+` button live in the native iOS Stack header (declared via
+ * Stack.Screen options in parent `_layout.tsx`, overridden here to add
+ * `headerRight`). Rendering an in-body title row on top of the native bar
+ * would stack two "Projects" labels vertically.
+ *
+ * Sort: client-side by `updated_at` desc — most recently touched at top.
+ * Mirrors web's default list ordering. WS `project:*` events keep the cache
+ * fresh via the listing-level realtime hook (`useProjectsRealtime` in
+ * `_layout.tsx`), so pull-to-refresh is rarely needed but kept for the
+ * cellular-edge case where a WS reconnect missed events.
*/
+import { useCallback, useMemo } from "react";
+import {
+ ActivityIndicator,
+ FlatList,
+ Pressable,
+ RefreshControl,
+ View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { useQuery } from "@tanstack/react-query";
+import { Stack, router } from "expo-router";
+import Svg, { Line } from "react-native-svg";
+import { Text } from "@/components/ui/text";
+import { Button } from "@/components/ui/button";
+import { ProjectRow } from "@/components/project/project-row";
+import { projectListOptions } from "@/data/queries/projects";
+import { useWorkspaceStore } from "@/data/workspace-store";
+
export default function ProjectsPage() {
+ const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
+ const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
+
+ const { data, isLoading, error, refetch, isRefetching } = useQuery(
+ projectListOptions(wsId),
+ );
+
+ const sorted = useMemo(() => {
+ if (!data) return [];
+ return [...data].sort(
+ (a, b) =>
+ new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(),
+ );
+ }, [data]);
+
+ const goCreate = useCallback(() => {
+ if (wsSlug) router.push(`/${wsSlug}/project/new`);
+ }, [wsSlug]);
+
+ const headerRight = useCallback(() => {
+ return ;
+ }, [goCreate]);
+
return (
-
-
- Projects coming soon.
+
+
+
+ {isLoading ? (
+
+
+
+ ) : error ? (
+
+
+ Failed to load projects:{" "}
+ {error instanceof Error ? error.message : "unknown error"}
+
+
+
+ ) : sorted.length === 0 ? (
+
+ ) : (
+ item.id}
+ ItemSeparatorComponent={() => (
+
+ )}
+ renderItem={({ item }) => (
+ {
+ if (wsSlug) router.push(`/${wsSlug}/project/${item.id}`);
+ }}
+ />
+ )}
+ refreshControl={
+
+ }
+ contentContainerClassName="pb-6"
+ />
+ )}
+
+ );
+}
+
+function PlusButton({ onPress }: { onPress: () => void }) {
+ return (
+
+
+
+ );
+}
+
+function EmptyState({ onCreate }: { onCreate: () => void }) {
+ return (
+
+
+ No projects yet
+
+ Group related issues into a project to track progress and assign a
+ lead.
+
+
);
}
diff --git a/apps/mobile/app/(app)/[workspace]/project/[id].tsx b/apps/mobile/app/(app)/[workspace]/project/[id].tsx
new file mode 100644
index 000000000..b22435c2e
--- /dev/null
+++ b/apps/mobile/app/(app)/[workspace]/project/[id].tsx
@@ -0,0 +1,273 @@
+/**
+ * Project detail screen. Single column, scrolling:
+ *
+ * Header card (icon + title + description, tap → edit)
+ * Properties section (Status / Priority / Lead — tap chip → picker)
+ * Resources section (read-only by default, "Add" button → resource form)
+ * Related issues (Open / Done bucketed list)
+ *
+ * Per-record realtime: `useProjectRealtime(id, onDeleted=back)` subscribes
+ * to `project:updated` (full replace) and `project:deleted` (pop back).
+ *
+ * Right-top "…" menu (ActionSheetIOS) → Edit / Delete. Delete asks for
+ * confirmation via `Alert.alert` per iOS HIG (destructive actions need
+ * a second tap).
+ */
+import { useCallback, useState } from "react";
+import {
+ ActionSheetIOS,
+ ActivityIndicator,
+ Alert,
+ Linking,
+ Platform,
+ Pressable,
+ RefreshControl,
+ ScrollView,
+ View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { Stack, router, useLocalSearchParams } from "expo-router";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { Ionicons } from "@expo/vector-icons";
+import type {
+ CreateProjectResourceRequest,
+ ProjectPriority,
+ ProjectStatus,
+} from "@multica/core/types";
+import { Text } from "@/components/ui/text";
+import { Button } from "@/components/ui/button";
+import { ProjectHeaderCard } from "@/components/project/project-header-card";
+import { ProjectPropertiesSection } from "@/components/project/project-properties-section";
+import { ProjectRelatedIssues } from "@/components/project/project-related-issues";
+import { ProjectResourcesSection } from "@/components/project/project-resources-section";
+import { ProjectStatusPickerSheet } from "@/components/project/pickers/project-status-picker-sheet";
+import { ProjectPriorityPickerSheet } from "@/components/project/pickers/project-priority-picker-sheet";
+import {
+ ProjectLeadPickerSheet,
+ type LeadValue,
+} from "@/components/project/pickers/project-lead-picker-sheet";
+import { AddResourceSheet } from "@/components/project/add-resource-sheet";
+import {
+ projectDetailOptions,
+ projectResourcesOptions,
+} from "@/data/queries/projects";
+import { issueKeys } from "@/data/queries/issue-keys";
+import {
+ useCreateProjectResource,
+ useDeleteProject,
+ useUpdateProject,
+} from "@/data/mutations/projects";
+import { useProjectRealtime } from "@/data/realtime/use-project-realtime";
+import { useWorkspaceStore } from "@/data/workspace-store";
+
+export default function ProjectDetail() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
+ const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
+ const qc = useQueryClient();
+
+ const detail = useQuery(projectDetailOptions(wsId, id));
+ const updateProject = useUpdateProject(id);
+ const deleteProject = useDeleteProject(id);
+ const createResource = useCreateProjectResource(id);
+
+ const [statusOpen, setStatusOpen] = useState(false);
+ const [priorityOpen, setPriorityOpen] = useState(false);
+ const [leadOpen, setLeadOpen] = useState(false);
+ const [resourceOpen, setResourceOpen] = useState(false);
+
+ // Per-record realtime — when another client deletes the project we're
+ // viewing, pop back so the user isn't stranded on a 404.
+ useProjectRealtime(id, () => router.back());
+
+ const onRefresh = useCallback(async () => {
+ await Promise.all([
+ detail.refetch(),
+ qc.invalidateQueries({ queryKey: projectResourcesOptions(wsId, id).queryKey }),
+ qc.invalidateQueries({
+ queryKey: [...issueKeys.list(wsId), "byProject", id],
+ }),
+ ]);
+ }, [detail, qc, wsId, id]);
+
+ const project = detail.data;
+
+ // EMPTY_PROJECT carries an empty id — parseWithFallback returned the
+ // fallback because the response shape drifted. Treat as "not found".
+ const projectMissing = !project || project.id === "";
+
+ const onPressMore = () => {
+ if (!project) return;
+ const wsUrl = process.env.EXPO_PUBLIC_WEB_URL;
+ const options = [
+ "Cancel",
+ "Edit details",
+ ...(wsUrl ? ["Open on web"] : []),
+ "Delete",
+ ];
+ const destructiveIndex = options.length - 1;
+ ActionSheetIOS.showActionSheetWithOptions(
+ {
+ options,
+ cancelButtonIndex: 0,
+ destructiveButtonIndex: destructiveIndex,
+ },
+ (i) => {
+ if (i === 1) {
+ if (wsSlug) router.push(`/${wsSlug}/project/${id}/edit`);
+ return;
+ }
+ if (wsUrl && i === 2) {
+ Linking.openURL(`${wsUrl}/${wsSlug}/projects/${id}`);
+ return;
+ }
+ if (i === destructiveIndex) {
+ onDelete();
+ }
+ },
+ );
+ };
+
+ const onDelete = () => {
+ Alert.alert(
+ "Delete project?",
+ "This cannot be undone. Issues in this project will become unassigned from any project.",
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Delete",
+ style: "destructive",
+ onPress: () => {
+ deleteProject.mutate(undefined, {
+ onSuccess: () => router.back(),
+ });
+ },
+ },
+ ],
+ );
+ };
+
+ const onAddResource = (body: CreateProjectResourceRequest) => {
+ createResource.mutate(body, {
+ onSuccess: () => setResourceOpen(false),
+ onError: (err) => {
+ Alert.alert(
+ "Failed to attach resource",
+ err instanceof Error ? err.message : "Unknown error",
+ );
+ },
+ });
+ };
+
+ return (
+
+ (
+
+
+
+ )
+ : undefined,
+ }}
+ />
+ {detail.isLoading ? (
+
+
+
+ ) : detail.error || projectMissing ? (
+
+
+ Failed to load project:{" "}
+ {detail.error instanceof Error
+ ? detail.error.message
+ : "not found"}
+
+
+
+ ) : (
+
+ }
+ keyboardDismissMode="on-drag"
+ >
+ {
+ if (wsSlug) router.push(`/${wsSlug}/project/${id}/edit`);
+ }}
+ />
+ setStatusOpen(true)}
+ onPressPriority={() => setPriorityOpen(true)}
+ onPressLead={() => setLeadOpen(true)}
+ />
+ setResourceOpen(true)}
+ />
+
+
+
+ )}
+
+ {project ? (
+ <>
+
+ updateProject.mutate({ status: next })
+ }
+ onClose={() => setStatusOpen(false)}
+ />
+
+ updateProject.mutate({ priority: next })
+ }
+ onClose={() => setPriorityOpen(false)}
+ />
+
+ updateProject.mutate(
+ next
+ ? { lead_type: next.type, lead_id: next.id }
+ : { lead_type: null, lead_id: null },
+ )
+ }
+ onClose={() => setLeadOpen(false)}
+ />
+ setResourceOpen(false)}
+ submitting={createResource.isPending}
+ />
+ >
+ ) : null}
+
+ );
+}
diff --git a/apps/mobile/app/(app)/[workspace]/project/[id]/edit.tsx b/apps/mobile/app/(app)/[workspace]/project/[id]/edit.tsx
new file mode 100644
index 000000000..21ce1f7ad
--- /dev/null
+++ b/apps/mobile/app/(app)/[workspace]/project/[id]/edit.tsx
@@ -0,0 +1,203 @@
+/**
+ * Edit project title / description / icon. Modal presentation, configured
+ * in `[workspace]/_layout.tsx`. Save button in the header runs an
+ * optimistic `useUpdateProject`; the modal dismisses on success.
+ *
+ * Cancel/dismiss flow: header Cancel + iOS drag-down gesture both check
+ * dirty state and pop an Alert if there are unsaved edits.
+ */
+import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ Alert,
+ KeyboardAvoidingView,
+ Platform,
+ Pressable,
+ ScrollView,
+ TextInput,
+ View,
+} from "react-native";
+import { Stack, router, useLocalSearchParams } from "expo-router";
+import { useQuery } from "@tanstack/react-query";
+import { Text } from "@/components/ui/text";
+import {
+ MIN_BODY_INPUT_HEIGHT_PX,
+ MOBILE_PLACEHOLDER_COLOR,
+} from "@/components/ui/input-tokens";
+import { projectDetailOptions } from "@/data/queries/projects";
+import { useUpdateProject } from "@/data/mutations/projects";
+import { useWorkspaceStore } from "@/data/workspace-store";
+
+export default function EditProject() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
+ const detail = useQuery(projectDetailOptions(wsId, id));
+ const update = useUpdateProject(id);
+
+ const [title, setTitle] = useState("");
+ const [description, setDescription] = useState("");
+ const [icon, setIcon] = useState("");
+ const [seeded, setSeeded] = useState(false);
+
+ // Seed local state once detail lands. Effect (not setState-in-render)
+ // so we don't accidentally retrigger on every parent re-render — the
+ // `seeded` guard makes it idempotent.
+ useEffect(() => {
+ if (!detail.data || seeded) return;
+ setTitle(detail.data.title);
+ setDescription(detail.data.description ?? "");
+ setIcon(detail.data.icon ?? "");
+ setSeeded(true);
+ }, [detail.data, seeded]);
+
+ const dirty = useMemo(() => {
+ if (!detail.data) return false;
+ return (
+ title.trim() !== detail.data.title ||
+ description.trim() !== (detail.data.description ?? "") ||
+ icon.trim() !== (detail.data.icon ?? "")
+ );
+ }, [detail.data, title, description, icon]);
+
+ const canSave =
+ seeded && title.trim().length > 0 && dirty && !update.isPending;
+
+ const onCancel = useCallback(() => {
+ if (!dirty) {
+ router.back();
+ return;
+ }
+ Alert.alert(
+ "Discard changes?",
+ "Your edits to this project will be lost.",
+ [
+ { text: "Keep editing", style: "cancel" },
+ {
+ text: "Discard",
+ style: "destructive",
+ onPress: () => router.back(),
+ },
+ ],
+ );
+ }, [dirty]);
+
+ const onSave = useCallback(() => {
+ if (!canSave) return;
+ const patch = {
+ title: title.trim(),
+ description: description.trim() || null,
+ icon: icon.trim() || null,
+ };
+ update.mutate(patch, {
+ onSuccess: () => router.back(),
+ onError: (err) => {
+ Alert.alert(
+ "Failed to save",
+ err instanceof Error ? err.message : "Unknown error",
+ );
+ },
+ });
+ }, [canSave, title, description, icon, update]);
+
+ const headerLeft = useCallback(() => {
+ return (
+
+ Cancel
+
+ );
+ }, [onCancel]);
+
+ const headerRight = useCallback(() => {
+ return (
+
+
+ {update.isPending ? "Saving…" : "Save"}
+
+
+ );
+ }, [canSave, onSave, update.isPending]);
+
+ return (
+ <>
+
+
+
+ {!detail.data ? (
+ Loading…
+ ) : (
+ <>
+
+ {
+ // Cap at two characters — emoji are usually 1-2 UTF-16
+ // code units. Prevents the user typing a full sentence
+ // by accident.
+ setIcon(v.slice(0, 4));
+ }}
+ placeholder="📦"
+ placeholderTextColor={MOBILE_PLACEHOLDER_COLOR}
+ className="text-2xl text-foreground bg-secondary/50 rounded-md px-3 py-2 self-start min-w-[60px] text-center"
+ maxLength={4}
+ />
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+ >
+ );
+}
+
+function Field({
+ label,
+ children,
+}: {
+ label: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {label}
+
+ {children}
+
+ );
+}
+
diff --git a/apps/mobile/app/(app)/[workspace]/project/new.tsx b/apps/mobile/app/(app)/[workspace]/project/new.tsx
new file mode 100644
index 000000000..d185c0f16
--- /dev/null
+++ b/apps/mobile/app/(app)/[workspace]/project/new.tsx
@@ -0,0 +1,248 @@
+/**
+ * New project modal. Mirrors `new-issue.tsx` shape — vertical form, header
+ * Cancel / Create buttons. Title is required; everything else has a default
+ * (status=planned, priority=none, no lead, no description, no icon).
+ *
+ * Lead is intentionally NOT exposed in the create form. Web does the same:
+ * lead assignment is a follow-up action because most users create the
+ * 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.
+ *
+ * On success: dismiss modal → navigate to the new project's detail page so
+ * the user can immediately add a lead / attach issues / configure properties.
+ */
+import { useCallback, useState } from "react";
+import {
+ Alert,
+ InteractionManager,
+ KeyboardAvoidingView,
+ Platform,
+ Pressable,
+ ScrollView,
+ TextInput,
+ 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 {
+ MIN_BODY_INPUT_HEIGHT_PX,
+ MOBILE_PLACEHOLDER_COLOR,
+} from "@/components/ui/input-tokens";
+import { ProjectStatusIcon } from "@/components/ui/project-status-icon";
+import { ProjectPriorityIcon } from "@/components/ui/project-priority-icon";
+import { ProjectStatusPickerSheet } from "@/components/project/pickers/project-status-picker-sheet";
+import { ProjectPriorityPickerSheet } from "@/components/project/pickers/project-priority-picker-sheet";
+import {
+ projectPriorityLabel,
+ projectStatusLabel,
+} from "@/lib/project-status";
+import { useCreateProject } from "@/data/mutations/projects";
+import { useWorkspaceStore } from "@/data/workspace-store";
+
+export default function NewProject() {
+ const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
+ const create = useCreateProject();
+
+ 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 dirty =
+ title.length > 0 ||
+ icon.length > 0 ||
+ description.length > 0 ||
+ status !== "planned" ||
+ priority !== "none";
+
+ const canCreate = title.trim().length > 0 && !create.isPending;
+
+ const onCancel = useCallback(() => {
+ if (!dirty) {
+ router.back();
+ return;
+ }
+ Alert.alert(
+ "Discard project?",
+ "Your draft will be lost.",
+ [
+ { text: "Keep editing", style: "cancel" },
+ {
+ text: "Discard",
+ style: "destructive",
+ onPress: () => router.back(),
+ },
+ ],
+ );
+ }, [dirty]);
+
+ const onCreate = useCallback(() => {
+ if (!canCreate) return;
+ create.mutate(
+ {
+ title: title.trim(),
+ description: description.trim() || undefined,
+ icon: icon.trim() || undefined,
+ status,
+ priority,
+ },
+ {
+ onSuccess: (project) => {
+ router.back();
+ // Wait for the modal dismiss animation to finish before pushing
+ // the detail screen. `InteractionManager` resolves once iOS
+ // says all in-flight animations / interactions are done — more
+ // robust than a hard-coded `setTimeout(150)` if iOS timing
+ // changes or the device is under load.
+ InteractionManager.runAfterInteractions(() => {
+ if (wsSlug) router.push(`/${wsSlug}/project/${project.id}`);
+ });
+ },
+ onError: (err) => {
+ Alert.alert(
+ "Failed to create project",
+ err instanceof Error ? err.message : "Unknown error",
+ );
+ },
+ },
+ );
+ }, [canCreate, create, title, description, icon, status, priority, wsSlug]);
+
+ const headerLeft = useCallback(() => {
+ return (
+
+ Cancel
+
+ );
+ }, [onCancel]);
+
+ const headerRight = useCallback(() => {
+ return (
+
+
+ {create.isPending ? "Creating…" : "Create"}
+
+
+ );
+ }, [canCreate, onCreate, create.isPending]);
+
+ return (
+ <>
+
+
+
+
+ setIcon(v.slice(0, 4))}
+ placeholder="📦"
+ placeholderTextColor={MOBILE_PLACEHOLDER_COLOR}
+ className="text-2xl text-foreground bg-secondary/50 rounded-md px-3 py-2 self-start min-w-[60px] text-center"
+ maxLength={4}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setStatusOpen(true)}
+ className="flex-row items-center gap-2 bg-secondary/50 rounded-md px-3 py-2.5"
+ >
+
+
+ {projectStatusLabel(status)}
+
+
+
+
+
+
+ setPriorityOpen(true)}
+ className="flex-row items-center gap-2 bg-secondary/50 rounded-md px-3 py-2.5"
+ >
+
+
+ {projectPriorityLabel(priority)}
+
+
+
+
+
+
+
+
+ setStatusOpen(false)}
+ />
+ setPriorityOpen(false)}
+ />
+ >
+ );
+}
+
+function Field({
+ label,
+ children,
+}: {
+ label: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {label}
+
+ {children}
+
+ );
+}
diff --git a/apps/mobile/components/project/add-resource-sheet.tsx b/apps/mobile/components/project/add-resource-sheet.tsx
new file mode 100644
index 000000000..a9d217d11
--- /dev/null
+++ b/apps/mobile/components/project/add-resource-sheet.tsx
@@ -0,0 +1,131 @@
+/**
+ * Attach a GitHub repo to a project. v1 only supports `github_repo` resource
+ * type — server will accept the JSON ref `{ url }`. Optional label so the
+ * row in the list reads as something the user picked rather than the raw URL.
+ *
+ * Minimal client-side validation: the URL must look like
+ * `https://github.com/owner/repo`. Anything else surfaces a Submit error
+ * from the server (real validation lives there).
+ *
+ * Modal shell mirrors other picker sheets — Pressable backdrop, centered
+ * card, tap-outside-to-dismiss. Phone keyboards push the card up
+ * naturally; no need for KeyboardAvoidingView at the modal scope.
+ */
+import { useState } from "react";
+import { Modal, Pressable, TextInput, View } from "react-native";
+import type { CreateProjectResourceRequest } from "@multica/core/types";
+import { Text } from "@/components/ui/text";
+import { Button } from "@/components/ui/button";
+import { MOBILE_PLACEHOLDER_COLOR } from "@/components/ui/input-tokens";
+
+interface Props {
+ visible: boolean;
+ onSubmit: (body: CreateProjectResourceRequest) => void;
+ onClose: () => void;
+ submitting?: boolean;
+}
+
+// Loose prefix match — accepts `owner/repo`, `owner/repo.git`,
+// `owner/repo/tree/main`, etc. Server is the canonical validator
+// (validateAndNormalizeResourceRef on the Go side); we only gate the
+// Attach button on "this looks like a GitHub repo URL at all".
+const GITHUB_PATTERN = /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+(\/|$)/i;
+
+export function AddResourceSheet({
+ visible,
+ onSubmit,
+ onClose,
+ submitting,
+}: Props) {
+ const [url, setUrl] = useState("");
+ const [label, setLabel] = useState("");
+
+ const reset = () => {
+ setUrl("");
+ setLabel("");
+ };
+
+ const close = () => {
+ reset();
+ onClose();
+ };
+
+ const valid = GITHUB_PATTERN.test(url.trim());
+
+ const submit = () => {
+ if (!valid || submitting) return;
+ onSubmit({
+ resource_type: "github_repo",
+ resource_ref: { url: url.trim() },
+ label: label.trim() || undefined,
+ });
+ reset();
+ };
+
+ return (
+
+
+
+ {}} className="w-full max-w-sm">
+
+
+ Attach GitHub repository
+
+
+
+ Repository URL
+
+
+
+
+
+ Label (optional)
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/components/project/pickers/project-lead-picker-sheet.tsx b/apps/mobile/components/project/pickers/project-lead-picker-sheet.tsx
new file mode 100644
index 000000000..f2ec9fb53
--- /dev/null
+++ b/apps/mobile/components/project/pickers/project-lead-picker-sheet.tsx
@@ -0,0 +1,230 @@
+/**
+ * Project lead picker. Single-select over members + agents, with a top
+ * "Unassigned" row to clear. Search bar filters by name.
+ *
+ * Shell mirrors issue/pickers/assignee-picker-sheet.tsx — a bottom-half
+ * modal with a search input on top, sectioned list below (Members on top,
+ * Agents below). Tap a row to apply (single-step).
+ */
+import { useMemo, useState } from "react";
+import {
+ ActivityIndicator,
+ Modal,
+ Pressable,
+ SectionList,
+ TextInput,
+ View,
+} from "react-native";
+import { useQuery } from "@tanstack/react-query";
+import { Ionicons } from "@expo/vector-icons";
+import type { Agent, MemberWithUser } from "@multica/core/types";
+import { Text } from "@/components/ui/text";
+import { ActorAvatar } from "@/components/ui/actor-avatar";
+import { MOBILE_PLACEHOLDER_COLOR } from "@/components/ui/input-tokens";
+import { agentListOptions } from "@/data/queries/agents";
+import { memberListOptions } from "@/data/queries/members";
+import { useWorkspaceStore } from "@/data/workspace-store";
+import { cn } from "@/lib/utils";
+
+export interface LeadValue {
+ type: "member" | "agent";
+ id: string;
+}
+
+interface Props {
+ visible: boolean;
+ value: LeadValue | null;
+ onChange: (next: LeadValue | null) => void;
+ onClose: () => void;
+}
+
+type RowItem =
+ | { kind: "member"; member: MemberWithUser }
+ | { kind: "agent"; agent: Agent };
+
+export function ProjectLeadPickerSheet({
+ visible,
+ value,
+ onChange,
+ onClose,
+}: Props) {
+ const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
+ const { data: members, isLoading: loadingMembers } = useQuery(
+ memberListOptions(wsId),
+ );
+ const { data: agents, isLoading: loadingAgents } = useQuery(
+ agentListOptions(wsId),
+ );
+
+ const [query, setQuery] = useState("");
+
+ const sections = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ const memberRows: RowItem[] = (members ?? [])
+ .filter((m) => !q || m.name.toLowerCase().includes(q))
+ .map((m) => ({ kind: "member" as const, member: m }));
+ const agentRows: RowItem[] = (agents ?? [])
+ .filter((a) => !q || a.name.toLowerCase().includes(q))
+ .map((a) => ({ kind: "agent" as const, agent: a }));
+ const out: Array<{ title: string; data: RowItem[] }> = [];
+ if (memberRows.length > 0) out.push({ title: "Members", data: memberRows });
+ if (agentRows.length > 0) out.push({ title: "Agents", data: agentRows });
+ return out;
+ }, [members, agents, query]);
+
+ const pick = (next: LeadValue | null) => {
+ onChange(next);
+ onClose();
+ };
+
+ const matches = (item: RowItem) => {
+ if (!value) return false;
+ if (item.kind === "member") {
+ return value.type === "member" && value.id === item.member.user_id;
+ }
+ return value.type === "agent" && value.id === item.agent.id;
+ };
+
+ return (
+
+
+
+ {}} className="w-full max-w-sm">
+
+
+
+
+ {loadingMembers || loadingAgents ? (
+
+
+
+ ) : (
+
+ item.kind === "member"
+ ? `m-${item.member.user_id}`
+ : `a-${item.agent.id}`
+ }
+ style={{ maxHeight: 420 }}
+ ListHeaderComponent={
+ pick(null)}
+ />
+ }
+ renderSectionHeader={({ section }) => (
+
+
+ {section.title}
+
+
+ )}
+ renderItem={({ item }) =>
+ item.kind === "member" ? (
+
+ pick({ type: "member", id: item.member.user_id })
+ }
+ />
+ ) : (
+ pick({ type: "agent", id: item.agent.id })}
+ />
+ )
+ }
+ ListEmptyComponent={
+
+
+ {query
+ ? "No matches."
+ : "No members or agents in this workspace yet."}
+
+
+ }
+ />
+ )}
+
+
+
+
+
+ );
+}
+
+function UnassignedRow({
+ checked,
+ onPress,
+}: {
+ checked: boolean;
+ onPress: () => void;
+}) {
+ return (
+
+
+ Unassigned
+ {checked ? ✓ : null}
+
+ );
+}
+
+function PickerRow({
+ name,
+ type,
+ id,
+ checked,
+ onPress,
+}: {
+ name: string;
+ type: "member" | "agent";
+ id: string;
+ checked: boolean;
+ onPress: () => void;
+}) {
+ return (
+
+
+
+ {name}
+
+ {checked ? ✓ : null}
+
+ );
+}
diff --git a/apps/mobile/components/project/pickers/project-priority-picker-sheet.tsx b/apps/mobile/components/project/pickers/project-priority-picker-sheet.tsx
new file mode 100644
index 000000000..1254a8c3b
--- /dev/null
+++ b/apps/mobile/components/project/pickers/project-priority-picker-sheet.tsx
@@ -0,0 +1,69 @@
+/**
+ * Project priority picker. Single-select over 5 ProjectPriority enum values.
+ * Shell mirrors project-status-picker-sheet.tsx.
+ */
+import { Modal, Pressable, View } from "react-native";
+import type { ProjectPriority } from "@multica/core/types";
+import { Text } from "@/components/ui/text";
+import { ProjectPriorityIcon } from "@/components/ui/project-priority-icon";
+import {
+ PROJECT_PRIORITIES,
+ PROJECT_PRIORITY_LABEL,
+} from "@/lib/project-status";
+import { cn } from "@/lib/utils";
+
+interface Props {
+ visible: boolean;
+ value: ProjectPriority | string;
+ onChange: (next: ProjectPriority) => void;
+ onClose: () => void;
+}
+
+export function ProjectPriorityPickerSheet({
+ visible,
+ value,
+ onChange,
+ onClose,
+}: Props) {
+ return (
+
+
+
+ {}} className="w-full max-w-sm">
+
+ {PROJECT_PRIORITIES.map((priority) => {
+ const selected = priority === value;
+ return (
+ {
+ onChange(priority);
+ onClose();
+ }}
+ className={cn(
+ "flex-row items-center gap-3 rounded-lg px-3 py-2.5 active:bg-secondary",
+ selected && "bg-secondary",
+ )}
+ >
+
+
+ {PROJECT_PRIORITY_LABEL[priority]}
+
+ {selected ? (
+ ✓
+ ) : null}
+
+ );
+ })}
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/components/project/pickers/project-status-picker-sheet.tsx b/apps/mobile/components/project/pickers/project-status-picker-sheet.tsx
new file mode 100644
index 000000000..795794881
--- /dev/null
+++ b/apps/mobile/components/project/pickers/project-status-picker-sheet.tsx
@@ -0,0 +1,73 @@
+/**
+ * Project status picker. Single-select over the 5 ProjectStatus enum values.
+ * Tap-to-apply (no confirm step); sheet auto-closes on selection.
+ *
+ * Modal shell mirrors issue/pickers/status-picker-sheet.tsx — same fade-in
+ * centered popover, same tap-outside-to-dismiss behavior, same selected-row
+ * styling.
+ */
+import { Modal, Pressable, View } from "react-native";
+import type { ProjectStatus } from "@multica/core/types";
+import { Text } from "@/components/ui/text";
+import { ProjectStatusIcon } from "@/components/ui/project-status-icon";
+import {
+ PROJECT_STATUSES,
+ PROJECT_STATUS_LABEL,
+} from "@/lib/project-status";
+import { cn } from "@/lib/utils";
+
+interface Props {
+ visible: boolean;
+ value: ProjectStatus | string;
+ onChange: (next: ProjectStatus) => void;
+ onClose: () => void;
+}
+
+export function ProjectStatusPickerSheet({
+ visible,
+ value,
+ onChange,
+ onClose,
+}: Props) {
+ return (
+
+
+
+ {}} className="w-full max-w-sm">
+
+ {PROJECT_STATUSES.map((status) => {
+ const selected = status === value;
+ return (
+ {
+ onChange(status);
+ onClose();
+ }}
+ className={cn(
+ "flex-row items-center gap-3 rounded-lg px-3 py-2.5 active:bg-secondary",
+ selected && "bg-secondary",
+ )}
+ >
+
+
+ {PROJECT_STATUS_LABEL[status]}
+
+ {selected ? (
+ ✓
+ ) : null}
+
+ );
+ })}
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/components/project/project-header-card.tsx b/apps/mobile/components/project/project-header-card.tsx
new file mode 100644
index 000000000..c2e02a5d0
--- /dev/null
+++ b/apps/mobile/components/project/project-header-card.tsx
@@ -0,0 +1,49 @@
+/**
+ * Header card for the project detail screen. Large emoji icon centered above
+ * the title, with the description shown in full (no truncation) below.
+ *
+ * Mirrors the visual emphasis of web's `project-header.tsx` but in a single
+ * vertical stack instead of the web sidebar layout — phones don't have the
+ * horizontal real estate for a side-by-side header + properties layout.
+ */
+import { Pressable, View } from "react-native";
+import type { Project } from "@multica/core/types";
+import { Text } from "@/components/ui/text";
+import { ProjectIcon } from "@/components/ui/project-icon";
+
+interface Props {
+ project: Project;
+ onEdit?: () => void;
+}
+
+export function ProjectHeaderCard({ project, onEdit }: Props) {
+ return (
+
+
+
+
+ {project.title}
+
+ {project.description ? (
+
+ {project.description}
+
+ ) : onEdit ? (
+
+ Add a description
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/mobile/components/project/project-properties-section.tsx b/apps/mobile/components/project/project-properties-section.tsx
new file mode 100644
index 000000000..5fbec6eb6
--- /dev/null
+++ b/apps/mobile/components/project/project-properties-section.tsx
@@ -0,0 +1,150 @@
+/**
+ * Project properties section. Tappable rows for Status / Priority / Lead.
+ * Each row opens a picker sheet via the corresponding `onPress*` callback.
+ *
+ * Layout mirrors iOS Settings rows: label on left, current value on right
+ * with a disclosure chevron, full-width separator below each row. Tapping
+ * anywhere on the row triggers the picker.
+ *
+ * Lead supports both member and agent (Project.lead_type), resolved via
+ * useActorLookup so it shares the same lookup with my-issues + issue detail.
+ */
+import { Pressable, View } from "react-native";
+import Svg, { Path } from "react-native-svg";
+import type { Project } from "@multica/core/types";
+import { Text } from "@/components/ui/text";
+import { ActorAvatar } from "@/components/ui/actor-avatar";
+import { ProjectStatusIcon } from "@/components/ui/project-status-icon";
+import { ProjectPriorityIcon } from "@/components/ui/project-priority-icon";
+import {
+ projectPriorityLabel,
+ projectStatusLabel,
+} from "@/lib/project-status";
+import { useActorLookup } from "@/data/use-actor-name";
+
+interface Props {
+ project: Project;
+ onPressStatus: () => void;
+ onPressPriority: () => void;
+ onPressLead: () => void;
+}
+
+export function ProjectPropertiesSection({
+ project,
+ onPressStatus,
+ onPressPriority,
+ onPressLead,
+}: Props) {
+ const { getName } = useActorLookup();
+ const leadName =
+ project.lead_type && project.lead_id
+ ? getName(project.lead_type, project.lead_id)
+ : null;
+
+ return (
+
+
}
+ right={
+
+ {projectStatusLabel(project.status)}
+
+ }
+ />
+
+
}
+ right={
+
+ {projectPriorityLabel(project.priority)}
+
+ }
+ />
+
+
+ ) : (
+
+ )
+ }
+ right={
+
+ {leadName ?? "Unassigned"}
+
+ }
+ />
+
+ );
+}
+
+function Row({
+ label,
+ onPress,
+ left,
+ right,
+}: {
+ label: string;
+ onPress: () => void;
+ left: React.ReactNode;
+ right: React.ReactNode;
+}) {
+ return (
+
+ {label}
+
+ {left}
+ {right}
+
+
+
+ );
+}
+
+function Separator() {
+ return ;
+}
+
+function Chevron() {
+ return (
+
+ );
+}
+
+function PlaceholderAvatar() {
+ return (
+
+ );
+}
diff --git a/apps/mobile/components/project/project-related-issues.tsx b/apps/mobile/components/project/project-related-issues.tsx
new file mode 100644
index 000000000..29df2b24e
--- /dev/null
+++ b/apps/mobile/components/project/project-related-issues.tsx
@@ -0,0 +1,223 @@
+/**
+ * Issues belonging to a project. Two-bucket FlatList: Open and Done.
+ *
+ * Status grouping mirrors web's project detail: anything except `done` and
+ * `cancelled` is bucketed as Open. `cancelled` is shown in the Done bucket
+ * (web does the same — once a project's issue is cancelled it's effectively
+ * out of the active work pile).
+ *
+ * Behavioral parity: row content uses the same priority icon + identifier
+ * + title + status icon layout as my-issues IssueRow so the visual identity
+ * is consistent across surfaces.
+ */
+import { useMemo, useState } from "react";
+import { ActivityIndicator, LayoutAnimation, Pressable, View } from "react-native";
+import { useQuery } from "@tanstack/react-query";
+import { router } from "expo-router";
+import Svg, { Path } from "react-native-svg";
+import type { Issue } from "@multica/core/types";
+import { Text } from "@/components/ui/text";
+import { PriorityIcon } from "@/components/ui/priority-icon";
+import { StatusIcon } from "@/components/ui/status-icon";
+import { ActorAvatar } from "@/components/ui/actor-avatar";
+import { projectIssuesOptions } from "@/data/queries/projects";
+import { useWorkspaceStore } from "@/data/workspace-store";
+
+interface Props {
+ projectId: string;
+}
+
+const DONE_STATUSES = new Set(["done", "cancelled"]);
+
+export function ProjectRelatedIssues({ projectId }: Props) {
+ const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
+ const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
+ const { data, isLoading, error } = useQuery(
+ projectIssuesOptions(wsId, projectId),
+ );
+
+ // Open expanded by default (active work); Done collapsed (housekeeping).
+ // Matches iOS Notes / Reminders pattern of "show what needs attention,
+ // hide what's done unless asked".
+ const [openExpanded, setOpenExpanded] = useState(true);
+ const [doneExpanded, setDoneExpanded] = useState(false);
+
+ const toggleOpen = () => {
+ // Native one-shot LayoutAnimation gives a smooth iOS-feeling
+ // expand/collapse without pulling in reanimated.
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
+ setOpenExpanded((v) => !v);
+ };
+ const toggleDone = () => {
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
+ setDoneExpanded((v) => !v);
+ };
+
+ const { open, done } = useMemo(() => {
+ const open: Issue[] = [];
+ const done: Issue[] = [];
+ for (const issue of data ?? []) {
+ if (DONE_STATUSES.has(issue.status)) {
+ done.push(issue);
+ } else {
+ open.push(issue);
+ }
+ }
+ return { open, done };
+ }, [data]);
+
+ const navigateToIssue = (id: string) => {
+ if (wsSlug) router.push(`/${wsSlug}/issue/${id}`);
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+ Failed to load issues:{" "}
+ {error instanceof Error ? error.message : "unknown error"}
+
+
+ );
+ }
+
+ const total = open.length + done.length;
+ if (total === 0) {
+ return (
+
+
+ No issues yet.
+
+
+ );
+ }
+
+ return (
+
+
+ {openExpanded
+ ? open.map((issue) => (
+ navigateToIssue(issue.id)}
+ />
+ ))
+ : null}
+ {done.length > 0 ? (
+ <>
+
+ {doneExpanded
+ ? done.map((issue) => (
+ navigateToIssue(issue.id)}
+ />
+ ))
+ : null}
+ >
+ ) : null}
+
+ );
+}
+
+function SectionHeader({
+ title,
+ count,
+ expanded,
+ onToggle,
+}: {
+ title: string;
+ count: number;
+ expanded: boolean;
+ onToggle: () => void;
+}) {
+ return (
+
+
+
+ {title}
+
+ {count}
+
+ );
+}
+
+function Chevron({ expanded }: { expanded: boolean }) {
+ // ▶ at rest, rotates to ▼ when expanded. Drawn as right-pointing in the
+ // SVG so the rotation transform reads as "open" without flipping
+ // orientation per state.
+ return (
+
+
+
+ );
+}
+
+function IssueRow({
+ issue,
+ onPress,
+}: {
+ issue: Issue;
+ onPress: () => void;
+}) {
+ return (
+
+
+
+
+
+ {issue.identifier}
+
+
+ {issue.title}
+
+ {issue.assignee_id &&
+ (issue.assignee_type === "member" || issue.assignee_type === "agent") ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/mobile/components/project/project-resources-section.tsx b/apps/mobile/components/project/project-resources-section.tsx
new file mode 100644
index 000000000..a98aae1c7
--- /dev/null
+++ b/apps/mobile/components/project/project-resources-section.tsx
@@ -0,0 +1,145 @@
+/**
+ * Project resources section. Read-mostly list of typed external pointers
+ * (today: GitHub repos). Tap a row to open the URL in the system browser.
+ * Long-press for delete (Pressable's onLongPress).
+ *
+ * Schema-tolerant by design — `resource_ref` is typed `unknown` in the
+ * mobile schema (server may extend the shape per resource_type). We narrow
+ * via `getRepoUrl()` only when the dispatch knows the type, so a future
+ * resource_type renders as a generic row with the label instead of crashing.
+ */
+import { ActivityIndicator, Alert, Linking, Pressable, View } from "react-native";
+import { useQuery } from "@tanstack/react-query";
+import { Ionicons } from "@expo/vector-icons";
+import type {
+ GithubRepoResourceRef,
+ ProjectResource,
+} from "@multica/core/types";
+import { Text } from "@/components/ui/text";
+import { projectResourcesOptions } from "@/data/queries/projects";
+import { useDeleteProjectResource } from "@/data/mutations/projects";
+import { useWorkspaceStore } from "@/data/workspace-store";
+
+interface Props {
+ projectId: string;
+ onAdd: () => void;
+}
+
+export function ProjectResourcesSection({ projectId, onAdd }: Props) {
+ const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
+ const { data: resources, isLoading } = useQuery(
+ projectResourcesOptions(wsId, projectId),
+ );
+ const remove = useDeleteProjectResource(projectId);
+
+ const onOpen = async (resource: ProjectResource) => {
+ const url = getResourceUrl(resource);
+ if (!url) return;
+ const canOpen = await Linking.canOpenURL(url);
+ if (canOpen) {
+ await Linking.openURL(url);
+ }
+ };
+
+ const onLongPress = (resource: ProjectResource) => {
+ Alert.alert(
+ "Detach resource?",
+ describeResource(resource),
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Detach",
+ style: "destructive",
+ onPress: () => remove.mutate(resource.id),
+ },
+ ],
+ );
+ };
+
+ return (
+
+
+
+ Resources
+
+
+ Add
+
+
+ {isLoading ? (
+
+
+
+ ) : !resources || resources.length === 0 ? (
+
+
+ No resources attached.
+
+
+ ) : (
+ resources.map((resource) => (
+ onOpen(resource)}
+ onLongPress={() => onLongPress(resource)}
+ />
+ ))
+ )}
+
+ );
+}
+
+function ResourceRow({
+ resource,
+ onPress,
+ onLongPress,
+}: {
+ resource: ProjectResource;
+ onPress: () => void;
+ onLongPress: () => void;
+}) {
+ return (
+
+
+
+
+ {resource.label ?? describeResource(resource)}
+
+ {resource.label ? (
+
+ {describeResource(resource)}
+
+ ) : null}
+
+
+ );
+}
+
+function iconFor(type: string): keyof typeof Ionicons.glyphMap {
+ if (type === "github_repo") return "logo-github";
+ return "link-outline";
+}
+
+function getResourceUrl(resource: ProjectResource): string | null {
+ if (resource.resource_type === "github_repo") {
+ const ref = resource.resource_ref as GithubRepoResourceRef | undefined;
+ return ref?.url ?? null;
+ }
+ // Unknown type — try a `.url` field as a generic fallback.
+ const ref = resource.resource_ref as { url?: unknown } | undefined;
+ return typeof ref?.url === "string" ? ref.url : null;
+}
+
+function describeResource(resource: ProjectResource): string {
+ return getResourceUrl(resource) ?? resource.resource_type;
+}
diff --git a/apps/mobile/components/project/project-row.tsx b/apps/mobile/components/project/project-row.tsx
new file mode 100644
index 000000000..7f7538df7
--- /dev/null
+++ b/apps/mobile/components/project/project-row.tsx
@@ -0,0 +1,77 @@
+/**
+ * Project list row. Mirrors the IssueRow layout shape from
+ * `(tabs)/my-issues.tsx` (left icon + flex title + right column for
+ * counts + time), per apps/mobile/CLAUDE.md "Visual alignment is baseline
+ * → row's right-side elements stack vertically into a column".
+ *
+ * Layout:
+ * [📦 icon] Project title [3/12]
+ * [● in progress] [▍▍ high] 2d ago
+ */
+import { Pressable, View } from "react-native";
+import type { Project } from "@multica/core/types";
+import { Text } from "@/components/ui/text";
+import { ProjectIcon } from "@/components/ui/project-icon";
+import { ProjectStatusIcon } from "@/components/ui/project-status-icon";
+import { ProjectPriorityIcon } from "@/components/ui/project-priority-icon";
+import {
+ projectPriorityLabel,
+ projectStatusLabel,
+} from "@/lib/project-status";
+import { timeAgo } from "@/lib/time-ago";
+
+interface Props {
+ project: Project;
+ onPress: () => void;
+}
+
+export function ProjectRow({ project, onPress }: Props) {
+ const totalIssues = project.issue_count;
+ const showCount = totalIssues > 0;
+
+ return (
+
+
+
+
+
+
+
+ {project.title}
+
+
+
+
+
+ {projectStatusLabel(project.status)}
+
+
+ {project.priority !== "none" ? (
+
+
+
+ {projectPriorityLabel(project.priority)}
+
+
+ ) : null}
+
+
+
+ {showCount ? (
+
+ {project.done_count}/{totalIssues}
+
+ ) : (
+ —
+ )}
+
+ {timeAgo(project.updated_at)}
+
+
+
+
+ );
+}
diff --git a/apps/mobile/components/ui/project-priority-icon.tsx b/apps/mobile/components/ui/project-priority-icon.tsx
new file mode 100644
index 000000000..df2a78920
--- /dev/null
+++ b/apps/mobile/components/ui/project-priority-icon.tsx
@@ -0,0 +1,71 @@
+/**
+ * Mobile ProjectPriorityIcon — reuses the same 4-bar geometry as the issue
+ * PriorityIcon. Project priority enum is identical to issue priority enum
+ * (urgent/high/medium/low/none), so visually identical bars communicate the
+ * same meaning across surfaces — desirable for behavioral parity.
+ *
+ * Colors are kept identical to the issue PriorityIcon hex map.
+ */
+import Svg, { Line, Rect } from "react-native-svg";
+import type { ProjectPriority } from "@multica/core/types";
+import { projectPriorityBars } from "@/lib/project-status";
+
+const COLOR: Record = {
+ urgent: "#dc2626",
+ high: "#eab308",
+ medium: "#eab308",
+ low: "#3b82f6",
+ none: "#71717a",
+};
+
+function colorFor(priority: string): string {
+ return (COLOR as Record)[priority] ?? COLOR.none;
+}
+
+export function ProjectPriorityIcon({
+ priority,
+ size = 14,
+}: {
+ priority: ProjectPriority | string;
+ size?: number;
+}) {
+ const filled = projectPriorityBars(priority);
+ const color = colorFor(priority);
+
+ if (filled === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/mobile/components/ui/project-status-icon.tsx b/apps/mobile/components/ui/project-status-icon.tsx
new file mode 100644
index 000000000..a39a381d3
--- /dev/null
+++ b/apps/mobile/components/ui/project-status-icon.tsx
@@ -0,0 +1,130 @@
+/**
+ * Mobile ProjectStatusIcon — visual identity per status, mirrors the design
+ * intent of the web `project-status-icon` (and the issue StatusIcon shape).
+ *
+ * Geometry follows status-icon.tsx (14×14 viewBox, 6r outer ring) so the
+ * project status icon visually rhymes with issue statuses on the same screen.
+ */
+import * as React from "react";
+import Svg, { Circle, Line, Path } from "react-native-svg";
+import type { ProjectStatus } from "@multica/core/types";
+import { projectStatusColor } from "@/lib/project-status";
+
+const CX = 7;
+const CY = 7;
+const OUTER_R = 6;
+const FILL_R = 3.5;
+
+function piePath(progress: number): string {
+ const angle = 2 * Math.PI * progress;
+ const endX = CX + FILL_R * Math.sin(angle);
+ const endY = CY - FILL_R * Math.cos(angle);
+ const largeArc = progress > 0.5 ? 1 : 0;
+ return `M${CX},${CY} L${CX},${CY - FILL_R} A${FILL_R},${FILL_R} 0 ${largeArc},1 ${endX},${endY} Z`;
+}
+
+function Ring({
+ color,
+ children,
+}: {
+ color: string;
+ children?: React.ReactNode;
+}) {
+ return (
+ <>
+
+ {children}
+ >
+ );
+}
+
+function PauseBars({ color }: { color: string }) {
+ return (
+ <>
+
+
+ >
+ );
+}
+
+function CancelX({ color }: { color: string }) {
+ return (
+
+ );
+}
+
+function DoneCheck() {
+ return (
+
+ );
+}
+
+export function ProjectStatusIcon({
+ status,
+ size = 16,
+}: {
+ status: ProjectStatus | string;
+ size?: number;
+}) {
+ const color = projectStatusColor(status);
+ return (
+
+ );
+}
diff --git a/apps/mobile/data/api.ts b/apps/mobile/data/api.ts
index c6b63d3fb..8152bf3be 100644
--- a/apps/mobile/data/api.ts
+++ b/apps/mobile/data/api.ts
@@ -21,6 +21,8 @@ import type {
ChatSession,
Comment,
CreateIssueRequest,
+ CreateProjectRequest,
+ CreateProjectResourceRequest,
InboxItem,
Issue,
IssueLabelsResponse,
@@ -28,12 +30,16 @@ import type {
ListIssuesParams,
ListIssuesResponse,
ListLabelsResponse,
+ ListProjectResourcesResponse,
ListProjectsResponse,
MemberWithUser,
+ Project,
+ ProjectResource,
Reaction,
SendChatMessageResponse,
TimelinePage,
UpdateIssueRequest,
+ UpdateProjectRequest,
User,
Workspace,
} from "@multica/core/types";
@@ -53,9 +59,13 @@ import {
EMPTY_CHAT_PENDING_TASK,
EMPTY_CHAT_SESSION_LIST,
EMPTY_LIST_LABELS_RESPONSE,
+ EMPTY_LIST_PROJECT_RESOURCES_RESPONSE,
EMPTY_LIST_PROJECTS_RESPONSE,
+ EMPTY_PROJECT,
ListLabelsResponseSchema,
+ ListProjectResourcesResponseSchema,
ListProjectsResponseSchema,
+ ProjectSchema,
SendChatMessageResponseSchema,
} from "./schemas";
import { getCurrentSlug } from "./workspace-store";
@@ -487,6 +497,86 @@ class ApiClient {
);
}
+ async getProject(
+ id: string,
+ opts?: { signal?: AbortSignal },
+ ): Promise {
+ const raw = await this.fetch(`/api/projects/${id}`, {
+ signal: opts?.signal,
+ });
+ // Drift-safe parse — UI checks `data.id === ""` to render the
+ // "project not found / shape drifted" error state instead of a
+ // half-populated detail page.
+ return parseWithFallback(raw, ProjectSchema, EMPTY_PROJECT, {
+ endpoint: "GET /api/projects/:id",
+ });
+ }
+
+ // Write endpoints — no parseWithFallback (mirrors updateIssue:430). A
+ // malformed write response surfaces as an error so the optimistic
+ // patch rolls back; pretending the write succeeded with empty data
+ // would silently desync caches.
+ async createProject(body: CreateProjectRequest): Promise {
+ return this.fetch("/api/projects", {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+ }
+
+ async updateProject(
+ id: string,
+ body: UpdateProjectRequest,
+ ): Promise {
+ return this.fetch(`/api/projects/${id}`, {
+ method: "PUT",
+ body: JSON.stringify(body),
+ });
+ }
+
+ async deleteProject(id: string): Promise {
+ await this.fetch(`/api/projects/${id}`, { method: "DELETE" });
+ }
+
+ // --- Project resources ---
+ async listProjectResources(
+ projectId: string,
+ opts?: { signal?: AbortSignal },
+ ): Promise {
+ const raw = await this.fetch(
+ `/api/projects/${projectId}/resources`,
+ { signal: opts?.signal },
+ );
+ return parseWithFallback(
+ raw,
+ ListProjectResourcesResponseSchema,
+ EMPTY_LIST_PROJECT_RESOURCES_RESPONSE,
+ { endpoint: "GET /api/projects/:id/resources" },
+ );
+ }
+
+ async createProjectResource(
+ projectId: string,
+ body: CreateProjectResourceRequest,
+ ): Promise {
+ return this.fetch(
+ `/api/projects/${projectId}/resources`,
+ {
+ method: "POST",
+ body: JSON.stringify(body),
+ },
+ );
+ }
+
+ async deleteProjectResource(
+ projectId: string,
+ resourceId: string,
+ ): Promise {
+ await this.fetch(
+ `/api/projects/${projectId}/resources/${resourceId}`,
+ { method: "DELETE" },
+ );
+ }
+
// --- Chat ---
// Mirrors the surface area of packages/core/api/client.ts chat methods.
// v1 omits getChatSession + updateChatSession (rename) — see the v1 cut
diff --git a/apps/mobile/data/mutations/projects.ts b/apps/mobile/data/mutations/projects.ts
new file mode 100644
index 000000000..770b7f913
--- /dev/null
+++ b/apps/mobile/data/mutations/projects.ts
@@ -0,0 +1,204 @@
+/**
+ * Project mutations. Mirrors the optimistic-patch + event-always-wins pattern
+ * of `useUpdateIssue` (data/mutations/issues.ts:276): apply the patch to
+ * both list and detail caches up-front, server response or WS event later
+ * overwrites with authoritative state.
+ *
+ * Cache shapes touched:
+ * - projectKeys.list(wsId) → `Project[]` (patch in place)
+ * - projectKeys.detail(wsId,id) → `Project` (replace fully)
+ * - projectKeys.resources(...) → `ProjectResource[]` (append / filter)
+ *
+ * No realtime-driven `project:*` updaters exist on web yet (see
+ * apps/mobile/CLAUDE.md realtime section) so mobile mirrors the design
+ * — mobile-owned ws-updaters live in `data/realtime/project-ws-updaters.ts`
+ * and are invoked by `use-projects-realtime.ts` + `use-project-realtime.ts`.
+ */
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import type {
+ CreateProjectRequest,
+ CreateProjectResourceRequest,
+ Project,
+ ProjectResource,
+ UpdateProjectRequest,
+} from "@multica/core/types";
+import { api } from "@/data/api";
+import { projectKeys } from "@/data/queries/projects";
+import { useWorkspaceStore } from "@/data/workspace-store";
+
+export function useCreateProject() {
+ const qc = useQueryClient();
+ const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
+
+ return useMutation({
+ mutationFn: (body: CreateProjectRequest) => api.createProject(body),
+ onSuccess: (project) => {
+ // Seed the detail cache so the post-create navigation lands on a
+ // populated page (no spinner flash). The list cache gets a prepend
+ // — list ordering is server-driven, so a brief out-of-order render
+ // is acceptable and corrected by the WS `project:created` event
+ // (or the next refetch).
+ qc.setQueryData(projectKeys.detail(wsId, project.id), project);
+ qc.setQueryData(projectKeys.list(wsId), (old) =>
+ old ? [project, ...old.filter((p) => p.id !== project.id)] : [project],
+ );
+ },
+ });
+}
+
+export function useUpdateProject(projectId: string) {
+ const qc = useQueryClient();
+ const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
+
+ return useMutation({
+ mutationKey: ["updateProject", projectId] as const,
+ mutationFn: (patch: UpdateProjectRequest) =>
+ api.updateProject(projectId, patch),
+ onMutate: async (patch) => {
+ const detailKey = projectKeys.detail(wsId, projectId);
+ const listKey = projectKeys.list(wsId);
+ // Cancel both — a concurrent list refetch can race-overwrite the
+ // optimistic patch otherwise (brief stale flash on screen).
+ await Promise.all([
+ qc.cancelQueries({ queryKey: detailKey }),
+ qc.cancelQueries({ queryKey: listKey }),
+ ]);
+
+ const prevDetail = qc.getQueryData(detailKey);
+ const prevList = qc.getQueryData(listKey);
+
+ if (prevDetail) {
+ qc.setQueryData(detailKey, { ...prevDetail, ...patch });
+ }
+ qc.setQueryData(listKey, (old) =>
+ old
+ ? old.map((p) => (p.id === projectId ? { ...p, ...patch } : p))
+ : old,
+ );
+
+ return { prevDetail, prevList, detailKey, listKey };
+ },
+ onError: (_err, _vars, ctx) => {
+ if (!ctx) return;
+ if (ctx.prevDetail !== undefined) {
+ qc.setQueryData(ctx.detailKey, ctx.prevDetail);
+ }
+ if (ctx.prevList !== undefined) {
+ qc.setQueryData(ctx.listKey, ctx.prevList);
+ }
+ },
+ onSuccess: (server) => {
+ // Server response is authoritative — replace the optimistic merge
+ // so any server-side normalisation (e.g. trimmed title) wins.
+ qc.setQueryData(projectKeys.detail(wsId, projectId), server);
+ qc.setQueryData(projectKeys.list(wsId), (old) =>
+ old
+ ? old.map((p) => (p.id === projectId ? server : p))
+ : old,
+ );
+ },
+ });
+}
+
+export function useDeleteProject(projectId: string) {
+ const qc = useQueryClient();
+ const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
+
+ return useMutation({
+ mutationKey: ["deleteProject", projectId] as const,
+ mutationFn: () => api.deleteProject(projectId),
+ onMutate: async () => {
+ const listKey = projectKeys.list(wsId);
+ await qc.cancelQueries({ queryKey: listKey });
+ const prevList = qc.getQueryData(listKey);
+ qc.setQueryData(listKey, (old) =>
+ old ? old.filter((p) => p.id !== projectId) : old,
+ );
+ return { prevList, listKey };
+ },
+ onError: (_err, _vars, ctx) => {
+ if (ctx?.prevList !== undefined) {
+ qc.setQueryData(ctx.listKey, ctx.prevList);
+ }
+ },
+ onSettled: () => {
+ qc.removeQueries({ queryKey: projectKeys.detail(wsId, projectId) });
+ qc.removeQueries({ queryKey: projectKeys.resources(wsId, projectId) });
+ },
+ });
+}
+
+export function useCreateProjectResource(projectId: string) {
+ const qc = useQueryClient();
+ const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
+
+ return useMutation({
+ mutationKey: ["createProjectResource", projectId] as const,
+ mutationFn: (body: CreateProjectResourceRequest) =>
+ api.createProjectResource(projectId, body),
+ onSuccess: (resource) => {
+ qc.setQueryData(
+ projectKeys.resources(wsId, projectId),
+ (old) =>
+ old
+ ? [...old.filter((r) => r.id !== resource.id), resource]
+ : [resource],
+ );
+ // Bump the parent's resource_count so the chip on detail/list
+ // increments without a refetch.
+ const bumpCount = (p: Project): Project => ({
+ ...p,
+ resource_count: p.resource_count + 1,
+ });
+ qc.setQueryData(
+ projectKeys.detail(wsId, projectId),
+ (old) => (old ? bumpCount(old) : old),
+ );
+ qc.setQueryData(projectKeys.list(wsId), (old) =>
+ old
+ ? old.map((p) => (p.id === projectId ? bumpCount(p) : p))
+ : old,
+ );
+ },
+ });
+}
+
+export function useDeleteProjectResource(projectId: string) {
+ const qc = useQueryClient();
+ const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
+
+ return useMutation({
+ mutationKey: ["deleteProjectResource", projectId] as const,
+ mutationFn: (resourceId: string) =>
+ api.deleteProjectResource(projectId, resourceId).then(() => resourceId),
+ onMutate: async (resourceId) => {
+ const key = projectKeys.resources(wsId, projectId);
+ await qc.cancelQueries({ queryKey: key });
+ const prev = qc.getQueryData(key);
+ qc.setQueryData(key, (old) =>
+ old ? old.filter((r) => r.id !== resourceId) : old,
+ );
+ return { prev, key };
+ },
+ onError: (_err, _vars, ctx) => {
+ if (ctx?.prev !== undefined) {
+ qc.setQueryData(ctx.key, ctx.prev);
+ }
+ },
+ onSuccess: () => {
+ const dropCount = (p: Project): Project => ({
+ ...p,
+ resource_count: Math.max(0, p.resource_count - 1),
+ });
+ qc.setQueryData(
+ projectKeys.detail(wsId, projectId),
+ (old) => (old ? dropCount(old) : old),
+ );
+ qc.setQueryData(projectKeys.list(wsId), (old) =>
+ old
+ ? old.map((p) => (p.id === projectId ? dropCount(p) : p))
+ : old,
+ );
+ },
+ });
+}
diff --git a/apps/mobile/data/queries/projects.ts b/apps/mobile/data/queries/projects.ts
index 9de9d40ee..f18d14302 100644
--- a/apps/mobile/data/queries/projects.ts
+++ b/apps/mobile/data/queries/projects.ts
@@ -1,18 +1,35 @@
/**
- * Workspace project list. Consumed by the read-only project chip on issue
- * detail and by `ProjectPickerSheet` in the new-issue / issue-detail flows.
+ * Workspace project queries. Three query shapes:
+ *
+ * - List (projectKeys.list) — `Project[]`
+ * - Detail (projectKeys.detail) — `Project`
+ * - Resources (projectKeys.resources) — `ProjectResource[]` (per project)
+ *
+ * Detail and Resources are workspace-scoped via the `wsId` segment so
+ * switching workspaces flips the cache without manual invalidate, per the
+ * root CLAUDE.md "Workspace-scoped queries must key on wsId" rule.
+ *
+ * Issues belonging to a project are NOT a project query — they live under
+ * `issueKeys.list(wsId, { project_id })` and reuse the issues cache shape.
+ * See `projectIssuesOptions` below for the binding helper.
*/
import { queryOptions } from "@tanstack/react-query";
import type { Project } from "@multica/core/types";
import { api } from "@/data/api";
+import { issueKeys } from "@/data/queries/issue-keys";
export const projectKeys = {
all: (wsId: string | null) => ["projects", wsId] as const,
+ list: (wsId: string | null) => [...projectKeys.all(wsId), "list"] as const,
+ detail: (wsId: string | null, id: string) =>
+ [...projectKeys.all(wsId), "detail", id] as const,
+ resources: (wsId: string | null, id: string) =>
+ [...projectKeys.all(wsId), "detail", id, "resources"] as const,
};
export const projectListOptions = (wsId: string | null) =>
queryOptions({
- queryKey: projectKeys.all(wsId),
+ queryKey: projectKeys.list(wsId),
queryFn: async ({ signal }) => {
const res = await api.listProjects({ signal });
return res.projects;
@@ -20,6 +37,46 @@ export const projectListOptions = (wsId: string | null) =>
enabled: !!wsId,
});
+export const projectDetailOptions = (wsId: string | null, id: string) =>
+ queryOptions({
+ queryKey: projectKeys.detail(wsId, id),
+ queryFn: ({ signal }) => api.getProject(id, { signal }),
+ enabled: !!wsId && !!id,
+ });
+
+export const projectResourcesOptions = (wsId: string | null, id: string) =>
+ queryOptions({
+ queryKey: projectKeys.resources(wsId, id),
+ queryFn: async ({ signal }) => {
+ const res = await api.listProjectResources(id, { signal });
+ return res.resources;
+ },
+ enabled: !!wsId && !!id,
+ });
+
+/**
+ * Issues filtered by `project_id`. Lives under the issues cache prefix
+ * (not the projects one) so a WS `issue:*` event invalidating
+ * `issueKeys.list(wsId)` also refreshes this list — single source of
+ * truth for issue caches.
+ */
+export const projectIssuesOptions = (wsId: string | null, projectId: string) =>
+ queryOptions({
+ queryKey: [
+ ...issueKeys.list(wsId),
+ "byProject",
+ projectId,
+ ] as const,
+ queryFn: async ({ signal }) => {
+ const res = await api.listIssues(
+ { project_id: projectId },
+ { signal },
+ );
+ return res.issues;
+ },
+ enabled: !!wsId && !!projectId,
+ });
+
/**
* Helper for the read-only project chip — returns the project matching id,
* or undefined. Caller selects from the list query and looks up by id.
diff --git a/apps/mobile/data/realtime/project-ws-updaters.ts b/apps/mobile/data/realtime/project-ws-updaters.ts
new file mode 100644
index 000000000..e20ab7924
--- /dev/null
+++ b/apps/mobile/data/realtime/project-ws-updaters.ts
@@ -0,0 +1,81 @@
+/**
+ * Mobile-owned WS cache patchers for the project domain. Pure functions over
+ * `QueryClient` — no React, no WS plumbing. Hooks in `use-projects-realtime.ts`
+ * and `use-project-realtime.ts` translate WS events into calls into this module.
+ *
+ * Why mobile-owned (and not importing from packages/core/projects):
+ * - Web doesn't have project ws-updaters yet — it invalidates via the
+ * query cache mutation surface. Mobile must patch (cellular-data rule
+ * in apps/mobile/CLAUDE.md realtime § "Patch over invalidate").
+ * - Even when web adds them, mobile keys come from its own
+ * `data/queries/projects.ts` factory; binding to a foreign factory
+ * would silently drift on key-shape changes.
+ *
+ * Cache shapes:
+ * - Project list (projectKeys.list) → `Project[]`
+ * - Project detail (projectKeys.detail) → `Project`
+ * - Resources (projectKeys.resources) → `ProjectResource[]`
+ */
+import type { QueryClient } from "@tanstack/react-query";
+import type { Project } from "@multica/core/types";
+import { projectKeys } from "@/data/queries/projects";
+
+export function patchProjectsList(
+ qc: QueryClient,
+ wsId: string,
+ partial: Partial & { id: string },
+) {
+ qc.setQueryData(projectKeys.list(wsId), (old) =>
+ old
+ ? old.map((p) => (p.id === partial.id ? { ...p, ...partial } : p))
+ : old,
+ );
+}
+
+/** Prepend if not present, replace in place if it is. List ordering is
+ * server-driven; on `project:created` the list will resync to the
+ * authoritative order via the next refetch / reconnect. */
+export function upsertIntoProjectsList(
+ qc: QueryClient,
+ wsId: string,
+ project: Project,
+) {
+ qc.setQueryData(projectKeys.list(wsId), (old) => {
+ if (!old) return [project];
+ const idx = old.findIndex((p) => p.id === project.id);
+ if (idx === -1) return [project, ...old];
+ const copy = old.slice();
+ copy[idx] = project;
+ return copy;
+ });
+}
+
+export function removeFromProjectsList(
+ qc: QueryClient,
+ wsId: string,
+ projectId: string,
+) {
+ qc.setQueryData(projectKeys.list(wsId), (old) =>
+ old ? old.filter((p) => p.id !== projectId) : old,
+ );
+}
+
+export function patchProjectDetail(
+ qc: QueryClient,
+ wsId: string,
+ project: Project,
+) {
+ // Full replace — payload carries the authoritative Project. We don't merge
+ // because server can clear nullable fields (description / lead) which a
+ // partial spread would erase silently if the payload omitted the key.
+ qc.setQueryData(projectKeys.detail(wsId, project.id), project);
+}
+
+export function clearProjectDetail(
+ qc: QueryClient,
+ wsId: string,
+ projectId: string,
+) {
+ qc.removeQueries({ queryKey: projectKeys.detail(wsId, projectId) });
+ qc.removeQueries({ queryKey: projectKeys.resources(wsId, projectId) });
+}
diff --git a/apps/mobile/data/realtime/use-project-realtime.ts b/apps/mobile/data/realtime/use-project-realtime.ts
new file mode 100644
index 000000000..f4607973b
--- /dev/null
+++ b/apps/mobile/data/realtime/use-project-realtime.ts
@@ -0,0 +1,131 @@
+/**
+ * Per-project realtime subscriptions. Mounted by the project detail screen
+ * with the active project id; cleans up on navigate-away.
+ *
+ * Filters every event by id match (`project.id === projectId` for project
+ * events, `issue.project_id === projectId` for issue events) so the hook
+ * only mutates the caches it owns (apps/mobile/CLAUDE.md "Realtime → Mount
+ * strategy").
+ *
+ * Handles:
+ * - project:updated → replace detail cache (payload is full Project).
+ * - project:deleted → drop detail + resources, then fire `onDeleted` so
+ * the screen pops back instead of stranding the user
+ * on a 404 page.
+ * - issue:updated/created/deleted → patch the related-issues cache so
+ * the list under the project stays in sync.
+ * Listing-level hooks (use-my-issues-realtime) only
+ * patch `issueKeys.myAll(wsId)`; this cache lives
+ * under `issueKeys.list(wsId)` with a "byProject"
+ * suffix and isn't covered by them.
+ * - reconnect → invalidate detail + resources + related-issues
+ * (we may have missed events while disconnected).
+ *
+ * `project:created` is not relevant to the per-record hook (no id match).
+ */
+import { useEffect } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import type {
+ Issue,
+ IssueCreatedPayload,
+ IssueDeletedPayload,
+ IssueUpdatedPayload,
+ ProjectDeletedPayload,
+ ProjectUpdatedPayload,
+} from "@multica/core/types";
+import { issueKeys } from "@/data/queries/issue-keys";
+import { projectKeys } from "@/data/queries/projects";
+import { useWorkspaceStore } from "@/data/workspace-store";
+import { useWSClient } from "./realtime-provider";
+import {
+ clearProjectDetail,
+ patchProjectDetail,
+ removeFromProjectsList,
+} from "./project-ws-updaters";
+
+export function useProjectRealtime(
+ projectId: string | undefined,
+ onDeleted?: () => void,
+) {
+ const ws = useWSClient();
+ const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
+ const qc = useQueryClient();
+
+ useEffect(() => {
+ if (!ws || !wsId || !projectId) return;
+
+ const issueListKey = [
+ ...issueKeys.list(wsId),
+ "byProject",
+ projectId,
+ ] as const;
+
+ const invalidateThisProject = () => {
+ qc.invalidateQueries({ queryKey: projectKeys.detail(wsId, projectId) });
+ qc.invalidateQueries({
+ queryKey: projectKeys.resources(wsId, projectId),
+ });
+ qc.invalidateQueries({ queryKey: issueListKey });
+ };
+
+ const unsubs: Array<() => void> = [
+ // Project-level events
+ ws.on("project:updated", (p) => {
+ const payload = p as ProjectUpdatedPayload;
+ if (payload.project.id !== projectId) return;
+ patchProjectDetail(qc, wsId, payload.project);
+ }),
+ ws.on("project:deleted", (p) => {
+ const payload = p as ProjectDeletedPayload;
+ if (payload.project_id !== projectId) return;
+ clearProjectDetail(qc, wsId, projectId);
+ removeFromProjectsList(qc, wsId, projectId);
+ onDeleted?.();
+ }),
+
+ // Issue events for issues IN this project — patch the byProject cache
+ // directly so the list stays fresh without a refetch.
+ ws.on("issue:updated", (p) => {
+ const payload = p as IssueUpdatedPayload;
+ const issue = payload.issue;
+ // Status / project_id changes both matter:
+ // - if it was in this project and still is: replace in place
+ // - if it just moved INTO this project: append (server is authority on order)
+ // - if it just moved OUT: remove from this list
+ const wasInList = (qc.getQueryData(issueListKey) ?? []).some(
+ (i) => i.id === issue.id,
+ );
+ const nowInProject = issue.project_id === projectId;
+ if (!wasInList && !nowInProject) return;
+ qc.setQueryData(issueListKey, (old) => {
+ if (!old) return old;
+ if (nowInProject) {
+ return old.some((i) => i.id === issue.id)
+ ? old.map((i) => (i.id === issue.id ? issue : i))
+ : [...old, issue];
+ }
+ return old.filter((i) => i.id !== issue.id);
+ });
+ }),
+ ws.on("issue:created", (p) => {
+ const payload = p as IssueCreatedPayload;
+ if (payload.issue.project_id !== projectId) return;
+ // Server is the authority on list position — invalidate so we
+ // refetch with the correct ordering rather than guessing.
+ qc.invalidateQueries({ queryKey: issueListKey });
+ }),
+ ws.on("issue:deleted", (p) => {
+ const payload = p as IssueDeletedPayload;
+ qc.setQueryData(issueListKey, (old) =>
+ old ? old.filter((i) => i.id !== payload.issue_id) : old,
+ );
+ }),
+
+ ws.onReconnect(invalidateThisProject),
+ ];
+
+ return () => {
+ for (const unsub of unsubs) unsub();
+ };
+ }, [ws, wsId, projectId, qc, onDeleted]);
+}
diff --git a/apps/mobile/data/realtime/use-projects-realtime.ts b/apps/mobile/data/realtime/use-projects-realtime.ts
new file mode 100644
index 000000000..5569643b9
--- /dev/null
+++ b/apps/mobile/data/realtime/use-projects-realtime.ts
@@ -0,0 +1,70 @@
+/**
+ * Projects realtime — listing-level subscriptions. Mounted globally
+ * (workspace-session-lifetime) alongside `useMyIssuesRealtime` so the
+ * project list stays fresh even if the user is on chat or an issue.
+ *
+ * Event coverage:
+ * - project:created → upsert into the list cache. The payload carries
+ * the full Project; no refetch.
+ * - project:updated → patch list + detail (full replace on detail).
+ * - project:deleted → strip from list and drop detail + resources caches.
+ * - reconnect → invalidate project list (we may have missed
+ * create/delete events while disconnected).
+ *
+ * Per the patch-over-invalidate rule in apps/mobile/CLAUDE.md "Realtime →
+ * Patch over invalidate (cellular-data rule)", every event with a full
+ * payload patches the cache directly.
+ */
+import { useEffect } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import type {
+ ProjectCreatedPayload,
+ ProjectDeletedPayload,
+ ProjectUpdatedPayload,
+} from "@multica/core/types";
+import { projectKeys } from "@/data/queries/projects";
+import { useWorkspaceStore } from "@/data/workspace-store";
+import { useWSClient } from "./realtime-provider";
+import {
+ clearProjectDetail,
+ patchProjectDetail,
+ patchProjectsList,
+ removeFromProjectsList,
+ upsertIntoProjectsList,
+} from "./project-ws-updaters";
+
+export function useProjectsRealtime() {
+ const ws = useWSClient();
+ const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
+ const qc = useQueryClient();
+
+ useEffect(() => {
+ if (!ws || !wsId) return;
+
+ const invalidateList = () => {
+ qc.invalidateQueries({ queryKey: projectKeys.list(wsId) });
+ };
+
+ const unsubs: Array<() => void> = [
+ ws.on("project:created", (p) => {
+ const payload = p as ProjectCreatedPayload;
+ upsertIntoProjectsList(qc, wsId, payload.project);
+ }),
+ ws.on("project:updated", (p) => {
+ const payload = p as ProjectUpdatedPayload;
+ patchProjectsList(qc, wsId, payload.project);
+ patchProjectDetail(qc, wsId, payload.project);
+ }),
+ ws.on("project:deleted", (p) => {
+ const payload = p as ProjectDeletedPayload;
+ removeFromProjectsList(qc, wsId, payload.project_id);
+ clearProjectDetail(qc, wsId, payload.project_id);
+ }),
+ ws.onReconnect(invalidateList),
+ ];
+
+ return () => {
+ for (const unsub of unsubs) unsub();
+ };
+ }, [ws, wsId, qc]);
+}
diff --git a/apps/mobile/data/schemas.ts b/apps/mobile/data/schemas.ts
index 19b69a905..f51c8d834 100644
--- a/apps/mobile/data/schemas.ts
+++ b/apps/mobile/data/schemas.ts
@@ -18,8 +18,10 @@ import type {
IssueLabelsResponse,
Label,
ListLabelsResponse,
+ ListProjectResourcesResponse,
ListProjectsResponse,
Project,
+ ProjectResource,
SendChatMessageResponse,
} from "@multica/core/types";
@@ -72,7 +74,7 @@ export const EMPTY_ISSUE_LABELS_RESPONSE: IssueLabelsResponse = {
labels: [],
};
-const ProjectSchema = z.object({
+export const ProjectSchema = z.object({
id: z.string(),
workspace_id: z.string(),
title: z.string(),
@@ -99,6 +101,55 @@ export const EMPTY_LIST_PROJECTS_RESPONSE: ListProjectsResponse = {
total: 0,
};
+// Fallback for `GET /api/projects/{id}` when the response shape drifts.
+// `id` defaults to empty — caller can detect "not found / drift" by checking
+// `data.id === ""` and rendering an error state instead of pretending the
+// data is valid. Status / priority cast to the enum literals so TS callers
+// downstream still flow correctly; runtime values came from the schema
+// (`z.string()`), which would have already passed.
+export const EMPTY_PROJECT: Project = {
+ id: "",
+ workspace_id: "",
+ title: "",
+ description: null,
+ icon: null,
+ status: "planned",
+ priority: "none",
+ lead_type: null,
+ lead_id: null,
+ created_at: "",
+ updated_at: "",
+ issue_count: 0,
+ done_count: 0,
+ resource_count: 0,
+};
+
+// Project resources are typed pointers to external resources (today: GitHub
+// repos). resource_ref shape varies per resource_type; lenient on both
+// `resource_type` (so a future type doesn't crash the list) and
+// `resource_ref` (passes through unchanged for the renderer to dispatch on).
+const ProjectResourceSchema = z.object({
+ id: z.string(),
+ project_id: z.string(),
+ workspace_id: z.string(),
+ resource_type: z.string(),
+ resource_ref: z.unknown(),
+ label: z.string().nullable(),
+ position: z.number().default(0),
+ created_at: z.string(),
+ created_by: z.string().nullable(),
+}).loose();
+
+export const ListProjectResourcesResponseSchema = z.object({
+ resources: z.array(ProjectResourceSchema).default([]),
+ total: z.number().default(0),
+}).loose();
+
+export const EMPTY_LIST_PROJECT_RESOURCES_RESPONSE: ListProjectResourcesResponse = {
+ resources: [],
+ total: 0,
+};
+
// =====================================================
// Chat (sessions / messages / pending task)
// =====================================================
@@ -161,4 +212,4 @@ export const SendChatMessageResponseSchema: z.ZodType =
}).loose();
// Helpers re-exported for ergonomic single-import at the call site.
-export type { Label, Project };
+export type { Label, Project, ProjectResource };
diff --git a/apps/mobile/lib/project-status.ts b/apps/mobile/lib/project-status.ts
new file mode 100644
index 000000000..e0476e1e8
--- /dev/null
+++ b/apps/mobile/lib/project-status.ts
@@ -0,0 +1,88 @@
+/**
+ * Mobile-owned project status + priority config. Mirror of
+ * `packages/core/projects/config.ts` — same enum order, same labels, same
+ * semantic colors. Mirrored (not imported) so mobile keeps full control of
+ * Tailwind tokens (we use the mobile tailwind palette, web/desktop use v4
+ * tokens with different class names like `text-warning`).
+ *
+ * Behavioral parity (apps/mobile/CLAUDE.md "Behavioral parity"):
+ * - Status enum order is identical to web. All 5 values render — `cancelled`
+ * is NOT hidden.
+ * - Priority enum order is identical to web. `none` renders as "No
+ * priority", not as an absence.
+ * - Labels are the canonical English strings; i18n lands later when
+ * mobile picks an i18n lib (web uses i18next).
+ */
+import type { ProjectPriority, ProjectStatus } from "@multica/core/types";
+
+export const PROJECT_STATUSES: ProjectStatus[] = [
+ "planned",
+ "in_progress",
+ "paused",
+ "completed",
+ "cancelled",
+];
+
+export const PROJECT_PRIORITIES: ProjectPriority[] = [
+ "urgent",
+ "high",
+ "medium",
+ "low",
+ "none",
+];
+
+export const PROJECT_STATUS_LABEL: Record = {
+ planned: "Planned",
+ in_progress: "In Progress",
+ paused: "Paused",
+ completed: "Completed",
+ cancelled: "Cancelled",
+};
+
+export const PROJECT_PRIORITY_LABEL: Record = {
+ urgent: "Urgent",
+ high: "High",
+ medium: "Medium",
+ low: "Low",
+ none: "No priority",
+};
+
+// Single hex per status, used by the SVG status icon (NativeWind classes
+// can't be read by Svg props at runtime). Matches the semantic intent of
+// the web tokens: planned/paused/cancelled are muted, in_progress is amber,
+// completed is blue.
+export const PROJECT_STATUS_COLOR: Record = {
+ planned: "#71717a",
+ in_progress: "#f59e0b",
+ paused: "#71717a",
+ completed: "#3b82f6",
+ cancelled: "#a1a1aa",
+};
+
+// Bar count for the priority icon (mirrors web's PROJECT_PRIORITY_CONFIG.bars).
+export const PROJECT_PRIORITY_BARS: Record = {
+ urgent: 4,
+ high: 3,
+ medium: 2,
+ low: 1,
+ none: 0,
+};
+
+// Fallback for unknown server values per "Enum drift downgrades, not crashes"
+// (root CLAUDE.md "API Response Compatibility"). Returns a sensible default
+// so a future enum value still renders a labelled chip.
+export function projectStatusLabel(value: string): string {
+ return (PROJECT_STATUS_LABEL as Record)[value] ?? value;
+}
+
+export function projectPriorityLabel(value: string): string {
+ return (PROJECT_PRIORITY_LABEL as Record)[value] ?? value;
+}
+
+export function projectStatusColor(value: string): string {
+ return (PROJECT_STATUS_COLOR as Record)[value] ?? "#71717a";
+}
+
+export function projectPriorityBars(value: string): number {
+ return (PROJECT_PRIORITY_BARS as Record)[value] ?? 0;
+}