feat(mobile): add projects feature with realtime cache sync

Mobile parity for the projects domain — browse, detail, create, edit,
delete, plus GitHub resource attach. UX adapted to iOS (Stack push +
modal sheets, picker sheets per property, ActionSheet for Edit/Delete,
collapsible Open/Done buckets in related issues) while preserving web's
semantics: 5 status enums (incl. cancelled), 5 priorities, lead supports
both members and agents, counts come from server fields.

Data layer follows mobile CLAUDE.md rules: parseWithFallback + signal
on every read, optimistic patch + WS event-always-wins on mutations,
mobile-owned ws-updaters (not imported from packages/core) that patch
over invalidate to honour the cellular-data rule. Per-record realtime
hook subscribes to issue:* events filtered by project_id so the
related-issues list stays fresh without pull-to-refresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-05-14 17:22:26 +08:00
parent 8285e90d78
commit ad9ba9d790
23 changed files with 2988 additions and 14 deletions

View File

@@ -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 <PlusButton onPress={goCreate} />;
}, [goCreate]);
return (
<View className="flex-1 items-center justify-center bg-background px-6">
<Text className="text-sm text-muted-foreground text-center">
Projects coming soon.
<SafeAreaView className="flex-1 bg-background" edges={[]}>
<Stack.Screen options={{ headerRight }} />
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator />
</View>
) : error ? (
<View className="px-4 gap-3 pt-4">
<Text className="text-sm text-destructive">
Failed to load projects:{" "}
{error instanceof Error ? error.message : "unknown error"}
</Text>
<Button variant="outline" onPress={() => refetch()}>
Retry
</Button>
</View>
) : sorted.length === 0 ? (
<EmptyState onCreate={goCreate} />
) : (
<FlatList
data={sorted}
keyExtractor={(item) => item.id}
ItemSeparatorComponent={() => (
<View className="h-px bg-border ml-4" />
)}
renderItem={({ item }) => (
<ProjectRow
project={item}
onPress={() => {
if (wsSlug) router.push(`/${wsSlug}/project/${item.id}`);
}}
/>
)}
refreshControl={
<RefreshControl refreshing={isRefetching} onRefresh={refetch} />
}
contentContainerClassName="pb-6"
/>
)}
</SafeAreaView>
);
}
function PlusButton({ onPress }: { onPress: () => void }) {
return (
<Pressable
onPress={onPress}
className="size-9 items-center justify-center rounded-md active:bg-secondary"
>
<Svg width={18} height={18} viewBox="0 0 16 16">
<Line
x1="8"
y1="3"
x2="8"
y2="13"
stroke="#0a84ff"
strokeWidth="1.5"
strokeLinecap="round"
/>
<Line
x1="3"
y1="8"
x2="13"
y2="8"
stroke="#0a84ff"
strokeWidth="1.5"
strokeLinecap="round"
/>
</Svg>
</Pressable>
);
}
function EmptyState({ onCreate }: { onCreate: () => void }) {
return (
<View className="flex-1 items-center justify-center px-6 gap-4">
<Text className="text-base font-medium text-foreground">
No projects yet
</Text>
<Text className="text-sm text-muted-foreground text-center">
Group related issues into a project to track progress and assign a
lead.
</Text>
<Button variant="default" onPress={onCreate}>
Create project
</Button>
</View>
);
}

View File

@@ -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 (
<SafeAreaView className="flex-1 bg-background" edges={["bottom"]}>
<Stack.Screen
options={{
title: project?.title || "Project",
headerBackTitle: "Back",
headerRight: project
? () => (
<Pressable onPress={onPressMore} className="px-2 py-1">
<Ionicons
name="ellipsis-horizontal"
size={20}
color={Platform.OS === "ios" ? "#0a84ff" : "#71717a"}
/>
</Pressable>
)
: undefined,
}}
/>
{detail.isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator />
</View>
) : detail.error || projectMissing ? (
<View className="flex-1 items-center justify-center px-6 gap-3">
<Text className="text-sm text-destructive text-center">
Failed to load project:{" "}
{detail.error instanceof Error
? detail.error.message
: "not found"}
</Text>
<Button variant="outline" onPress={() => detail.refetch()}>
Retry
</Button>
</View>
) : (
<ScrollView
contentContainerClassName="pb-10"
refreshControl={
<RefreshControl
refreshing={detail.isRefetching}
onRefresh={onRefresh}
/>
}
keyboardDismissMode="on-drag"
>
<ProjectHeaderCard
project={project}
onEdit={() => {
if (wsSlug) router.push(`/${wsSlug}/project/${id}/edit`);
}}
/>
<ProjectPropertiesSection
project={project}
onPressStatus={() => setStatusOpen(true)}
onPressPriority={() => setPriorityOpen(true)}
onPressLead={() => setLeadOpen(true)}
/>
<ProjectResourcesSection
projectId={id}
onAdd={() => setResourceOpen(true)}
/>
<View className="h-3" />
<ProjectRelatedIssues projectId={id} />
</ScrollView>
)}
{project ? (
<>
<ProjectStatusPickerSheet
visible={statusOpen}
value={project.status}
onChange={(next: ProjectStatus) =>
updateProject.mutate({ status: next })
}
onClose={() => setStatusOpen(false)}
/>
<ProjectPriorityPickerSheet
visible={priorityOpen}
value={project.priority}
onChange={(next: ProjectPriority) =>
updateProject.mutate({ priority: next })
}
onClose={() => setPriorityOpen(false)}
/>
<ProjectLeadPickerSheet
visible={leadOpen}
value={
project.lead_type && project.lead_id
? { type: project.lead_type, id: project.lead_id }
: null
}
onChange={(next: LeadValue | null) =>
updateProject.mutate(
next
? { lead_type: next.type, lead_id: next.id }
: { lead_type: null, lead_id: null },
)
}
onClose={() => setLeadOpen(false)}
/>
<AddResourceSheet
visible={resourceOpen}
onSubmit={onAddResource}
onClose={() => setResourceOpen(false)}
submitting={createResource.isPending}
/>
</>
) : null}
</SafeAreaView>
);
}

View File

@@ -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 (
<Pressable onPress={onCancel} className="px-1 py-1">
<Text className="text-base text-brand">Cancel</Text>
</Pressable>
);
}, [onCancel]);
const headerRight = useCallback(() => {
return (
<Pressable
onPress={onSave}
disabled={!canSave}
className={canSave ? "px-1 py-1" : "px-1 py-1 opacity-40"}
>
<Text className="text-base text-brand font-semibold">
{update.isPending ? "Saving…" : "Save"}
</Text>
</Pressable>
);
}, [canSave, onSave, update.isPending]);
return (
<>
<Stack.Screen options={{ headerLeft, headerRight }} />
<KeyboardAvoidingView
className="flex-1 bg-background"
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
className="flex-1"
contentContainerClassName="px-4 pt-4 pb-6 gap-4"
keyboardShouldPersistTaps="handled"
>
{!detail.data ? (
<Text className="text-sm text-muted-foreground">Loading</Text>
) : (
<>
<Field label="Icon (emoji)">
<TextInput
value={icon}
onChangeText={(v) => {
// 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}
/>
</Field>
<Field label="Title">
<TextInput
value={title}
onChangeText={setTitle}
placeholder="Project title"
placeholderTextColor={MOBILE_PLACEHOLDER_COLOR}
className="text-base text-foreground bg-secondary/50 rounded-md px-3 py-2"
autoFocus={!detail.data?.title}
returnKeyType="next"
/>
</Field>
<Field label="Description">
<TextInput
value={description}
onChangeText={setDescription}
placeholder="What is this project about?"
placeholderTextColor={MOBILE_PLACEHOLDER_COLOR}
className="text-base text-foreground bg-secondary/50 rounded-md px-3 py-2"
style={{ minHeight: MIN_BODY_INPUT_HEIGHT_PX }}
multiline
textAlignVertical="top"
/>
</Field>
</>
)}
</ScrollView>
</KeyboardAvoidingView>
</>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<View className="gap-1.5">
<Text className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</Text>
{children}
</View>
);
}

View File

@@ -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<ProjectStatus>("planned");
const [priority, setPriority] = useState<ProjectPriority>("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 (
<Pressable onPress={onCancel} className="px-1 py-1">
<Text className="text-base text-brand">Cancel</Text>
</Pressable>
);
}, [onCancel]);
const headerRight = useCallback(() => {
return (
<Pressable
onPress={onCreate}
disabled={!canCreate}
className={canCreate ? "px-1 py-1" : "px-1 py-1 opacity-40"}
>
<Text className="text-base text-brand font-semibold">
{create.isPending ? "Creating…" : "Create"}
</Text>
</Pressable>
);
}, [canCreate, onCreate, create.isPending]);
return (
<>
<Stack.Screen options={{ headerLeft, headerRight }} />
<KeyboardAvoidingView
className="flex-1 bg-background"
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
className="flex-1"
contentContainerClassName="px-4 pt-4 pb-6 gap-4"
keyboardShouldPersistTaps="handled"
>
<Field label="Icon (emoji)">
<TextInput
value={icon}
onChangeText={(v) => 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}
/>
</Field>
<Field label="Title">
<TextInput
value={title}
onChangeText={setTitle}
placeholder="Project title"
placeholderTextColor={MOBILE_PLACEHOLDER_COLOR}
className="text-base text-foreground bg-secondary/50 rounded-md px-3 py-2"
autoFocus
returnKeyType="next"
/>
</Field>
<Field label="Description">
<TextInput
value={description}
onChangeText={setDescription}
placeholder="What is this project about?"
placeholderTextColor={MOBILE_PLACEHOLDER_COLOR}
className="text-base text-foreground bg-secondary/50 rounded-md px-3 py-2"
style={{ minHeight: MIN_BODY_INPUT_HEIGHT_PX }}
multiline
textAlignVertical="top"
/>
</Field>
<View className="flex-row gap-2">
<View className="flex-1">
<Field label="Status">
<Pressable
onPress={() => setStatusOpen(true)}
className="flex-row items-center gap-2 bg-secondary/50 rounded-md px-3 py-2.5"
>
<ProjectStatusIcon status={status} size={16} />
<Text className="text-sm text-foreground flex-1">
{projectStatusLabel(status)}
</Text>
</Pressable>
</Field>
</View>
<View className="flex-1">
<Field label="Priority">
<Pressable
onPress={() => setPriorityOpen(true)}
className="flex-row items-center gap-2 bg-secondary/50 rounded-md px-3 py-2.5"
>
<ProjectPriorityIcon priority={priority} size={16} />
<Text className="text-sm text-foreground flex-1">
{projectPriorityLabel(priority)}
</Text>
</Pressable>
</Field>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
<ProjectStatusPickerSheet
visible={statusOpen}
value={status}
onChange={setStatus}
onClose={() => setStatusOpen(false)}
/>
<ProjectPriorityPickerSheet
visible={priorityOpen}
value={priority}
onChange={setPriority}
onClose={() => setPriorityOpen(false)}
/>
</>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<View className="gap-1.5">
<Text className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</Text>
{children}
</View>
);
}

View File

@@ -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 (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={close}
>
<Pressable className="flex-1 bg-black/40" onPress={close}>
<View className="flex-1 items-center justify-center px-6">
<Pressable onPress={() => {}} className="w-full max-w-sm">
<View className="bg-popover rounded-2xl p-4 gap-3">
<Text className="text-base font-semibold text-foreground">
Attach GitHub repository
</Text>
<View className="gap-1">
<Text className="text-xs text-muted-foreground">
Repository URL
</Text>
<TextInput
value={url}
onChangeText={setUrl}
placeholder="https://github.com/owner/repo"
placeholderTextColor={MOBILE_PLACEHOLDER_COLOR}
className="text-sm text-foreground bg-secondary/50 rounded-md px-3 py-2"
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
autoFocus
/>
</View>
<View className="gap-1">
<Text className="text-xs text-muted-foreground">
Label (optional)
</Text>
<TextInput
value={label}
onChangeText={setLabel}
placeholder="e.g. Backend"
placeholderTextColor={MOBILE_PLACEHOLDER_COLOR}
className="text-sm text-foreground bg-secondary/50 rounded-md px-3 py-2"
/>
</View>
<View className="flex-row justify-end gap-2 pt-1">
<Button
variant="outline"
size="sm"
onPress={close}
disabled={submitting}
>
Cancel
</Button>
<Button
size="sm"
onPress={submit}
disabled={!valid || submitting}
className={!valid || submitting ? "opacity-50" : undefined}
>
{submitting ? "Attaching…" : "Attach"}
</Button>
</View>
</View>
</Pressable>
</View>
</Pressable>
</Modal>
);
}

View File

@@ -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 (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<Pressable className="flex-1 bg-black/40" onPress={onClose}>
<View className="flex-1 items-center justify-center px-6">
<Pressable onPress={() => {}} className="w-full max-w-sm">
<View className="bg-popover rounded-2xl overflow-hidden">
<View className="px-3 pt-3 pb-2 border-b border-border">
<TextInput
value={query}
onChangeText={setQuery}
placeholder="Search members or agents"
placeholderTextColor={MOBILE_PLACEHOLDER_COLOR}
className="text-sm text-foreground bg-secondary/50 rounded-md px-3 py-2"
autoCapitalize="none"
autoCorrect={false}
/>
</View>
{loadingMembers || loadingAgents ? (
<View className="px-3 py-8 items-center">
<ActivityIndicator />
</View>
) : (
<SectionList
sections={sections}
keyExtractor={(item) =>
item.kind === "member"
? `m-${item.member.user_id}`
: `a-${item.agent.id}`
}
style={{ maxHeight: 420 }}
ListHeaderComponent={
<UnassignedRow
checked={value === null}
onPress={() => pick(null)}
/>
}
renderSectionHeader={({ section }) => (
<View className="bg-popover px-3 pt-2 pb-1">
<Text className="text-[11px] uppercase tracking-wider text-muted-foreground/70">
{section.title}
</Text>
</View>
)}
renderItem={({ item }) =>
item.kind === "member" ? (
<PickerRow
name={item.member.name}
type="member"
id={item.member.user_id}
checked={matches(item)}
onPress={() =>
pick({ type: "member", id: item.member.user_id })
}
/>
) : (
<PickerRow
name={item.agent.name}
type="agent"
id={item.agent.id}
checked={matches(item)}
onPress={() => pick({ type: "agent", id: item.agent.id })}
/>
)
}
ListEmptyComponent={
<View className="px-3 py-6 items-center">
<Text className="text-xs text-muted-foreground text-center">
{query
? "No matches."
: "No members or agents in this workspace yet."}
</Text>
</View>
}
/>
)}
</View>
</Pressable>
</View>
</Pressable>
</Modal>
);
}
function UnassignedRow({
checked,
onPress,
}: {
checked: boolean;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
className={cn(
"flex-row items-center gap-3 px-3 py-2.5 border-b border-border active:bg-secondary",
checked && "bg-secondary",
)}
>
<Ionicons
name="close-circle-outline"
size={20}
color={MOBILE_PLACEHOLDER_COLOR}
/>
<Text className="flex-1 text-sm text-muted-foreground">Unassigned</Text>
{checked ? <Text className="text-xs text-muted-foreground"></Text> : null}
</Pressable>
);
}
function PickerRow({
name,
type,
id,
checked,
onPress,
}: {
name: string;
type: "member" | "agent";
id: string;
checked: boolean;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
className={cn(
"flex-row items-center gap-3 px-3 py-2.5 active:bg-secondary",
checked && "bg-secondary",
)}
>
<ActorAvatar type={type} id={id} size={24} />
<Text className="flex-1 text-sm text-foreground" numberOfLines={1}>
{name}
</Text>
{checked ? <Text className="text-xs text-muted-foreground"></Text> : null}
</Pressable>
);
}

View File

@@ -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 (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<Pressable className="flex-1 bg-black/40" onPress={onClose}>
<View className="flex-1 items-center justify-center px-8">
<Pressable onPress={() => {}} className="w-full max-w-sm">
<View className="bg-popover rounded-2xl p-2">
{PROJECT_PRIORITIES.map((priority) => {
const selected = priority === value;
return (
<Pressable
key={priority}
onPress={() => {
onChange(priority);
onClose();
}}
className={cn(
"flex-row items-center gap-3 rounded-lg px-3 py-2.5 active:bg-secondary",
selected && "bg-secondary",
)}
>
<ProjectPriorityIcon priority={priority} size={18} />
<Text className="flex-1 text-sm text-foreground">
{PROJECT_PRIORITY_LABEL[priority]}
</Text>
{selected ? (
<Text className="text-xs text-muted-foreground"></Text>
) : null}
</Pressable>
);
})}
</View>
</Pressable>
</View>
</Pressable>
</Modal>
);
}

View File

@@ -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 (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<Pressable className="flex-1 bg-black/40" onPress={onClose}>
<View className="flex-1 items-center justify-center px-8">
<Pressable onPress={() => {}} className="w-full max-w-sm">
<View className="bg-popover rounded-2xl p-2">
{PROJECT_STATUSES.map((status) => {
const selected = status === value;
return (
<Pressable
key={status}
onPress={() => {
onChange(status);
onClose();
}}
className={cn(
"flex-row items-center gap-3 rounded-lg px-3 py-2.5 active:bg-secondary",
selected && "bg-secondary",
)}
>
<ProjectStatusIcon status={status} size={18} />
<Text className="flex-1 text-sm text-foreground">
{PROJECT_STATUS_LABEL[status]}
</Text>
{selected ? (
<Text className="text-xs text-muted-foreground"></Text>
) : null}
</Pressable>
);
})}
</View>
</Pressable>
</View>
</Pressable>
</Modal>
);
}

View File

@@ -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 (
<Pressable
onPress={onEdit}
disabled={!onEdit}
className="px-4 pt-4 pb-3 active:bg-secondary/40"
>
<View className="items-start gap-2">
<ProjectIcon icon={project.icon} size="lg" />
<Text
className="text-2xl font-bold text-foreground"
selectable
>
{project.title}
</Text>
{project.description ? (
<Text
className="text-sm text-muted-foreground"
selectable
>
{project.description}
</Text>
) : onEdit ? (
<Text className="text-sm text-muted-foreground/60 italic">
Add a description
</Text>
) : null}
</View>
</Pressable>
);
}

View File

@@ -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 (
<View className="border-y border-border bg-background">
<Row
label="Status"
onPress={onPressStatus}
left={<ProjectStatusIcon status={project.status} size={16} />}
right={
<Text className="text-sm text-foreground">
{projectStatusLabel(project.status)}
</Text>
}
/>
<Separator />
<Row
label="Priority"
onPress={onPressPriority}
left={<ProjectPriorityIcon priority={project.priority} size={16} />}
right={
<Text className="text-sm text-foreground">
{projectPriorityLabel(project.priority)}
</Text>
}
/>
<Separator />
<Row
label="Lead"
onPress={onPressLead}
left={
leadName ? (
<ActorAvatar
type={project.lead_type}
id={project.lead_id}
size={20}
/>
) : (
<PlaceholderAvatar />
)
}
right={
<Text
className={
leadName
? "text-sm text-foreground"
: "text-sm text-muted-foreground"
}
>
{leadName ?? "Unassigned"}
</Text>
}
/>
</View>
);
}
function Row({
label,
onPress,
left,
right,
}: {
label: string;
onPress: () => void;
left: React.ReactNode;
right: React.ReactNode;
}) {
return (
<Pressable
onPress={onPress}
className="flex-row items-center gap-3 px-4 py-3 active:bg-secondary"
>
<Text className="text-sm text-muted-foreground w-20">{label}</Text>
<View className="flex-row items-center gap-2 flex-1">
{left}
{right}
</View>
<Chevron />
</Pressable>
);
}
function Separator() {
return <View className="h-px bg-border ml-4" />;
}
function Chevron() {
return (
<Svg width={14} height={14} viewBox="0 0 16 16">
<Path
d="M6 4 L10 8 L6 12"
fill="none"
stroke="#a1a1aa"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
}
function PlaceholderAvatar() {
return (
<View
style={{ width: 20, height: 20, borderRadius: 10 }}
className="border border-dashed border-muted-foreground/40"
/>
);
}

View File

@@ -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 (
<View className="px-4 py-6 items-center">
<ActivityIndicator />
</View>
);
}
if (error) {
return (
<View className="px-4 py-6">
<Text className="text-sm text-destructive">
Failed to load issues:{" "}
{error instanceof Error ? error.message : "unknown error"}
</Text>
</View>
);
}
const total = open.length + done.length;
if (total === 0) {
return (
<View className="px-4 py-6">
<Text className="text-sm text-muted-foreground">
No issues yet.
</Text>
</View>
);
}
return (
<View>
<SectionHeader
title="Open"
count={open.length}
expanded={openExpanded}
onToggle={toggleOpen}
/>
{openExpanded
? open.map((issue) => (
<IssueRow
key={issue.id}
issue={issue}
onPress={() => navigateToIssue(issue.id)}
/>
))
: null}
{done.length > 0 ? (
<>
<SectionHeader
title="Done"
count={done.length}
expanded={doneExpanded}
onToggle={toggleDone}
/>
{doneExpanded
? done.map((issue) => (
<IssueRow
key={issue.id}
issue={issue}
onPress={() => navigateToIssue(issue.id)}
/>
))
: null}
</>
) : null}
</View>
);
}
function SectionHeader({
title,
count,
expanded,
onToggle,
}: {
title: string;
count: number;
expanded: boolean;
onToggle: () => void;
}) {
return (
<Pressable
onPress={onToggle}
className="flex-row items-center gap-2 px-4 py-2 bg-background active:bg-secondary"
>
<Chevron expanded={expanded} />
<Text className="text-xs uppercase tracking-wider text-muted-foreground font-medium">
{title}
</Text>
<Text className="text-xs text-muted-foreground/60">{count}</Text>
</Pressable>
);
}
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 (
<View
style={{
width: 12,
height: 12,
transform: [{ rotate: expanded ? "90deg" : "0deg" }],
}}
>
<Svg width={12} height={12} viewBox="0 0 16 16">
<Path
d="M6 4 L10 8 L6 12"
fill="none"
stroke="#71717a"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
</View>
);
}
function IssueRow({
issue,
onPress,
}: {
issue: Issue;
onPress: () => void;
}) {
return (
<Pressable onPress={onPress} className="active:bg-secondary px-4 py-3">
<View className="flex-row items-center gap-3">
<StatusIcon status={issue.status} size={14} />
<PriorityIcon priority={issue.priority} size={14} />
<Text className="text-xs text-muted-foreground shrink-0 w-16">
{issue.identifier}
</Text>
<Text className="flex-1 text-sm text-foreground" numberOfLines={1}>
{issue.title}
</Text>
{issue.assignee_id &&
(issue.assignee_type === "member" || issue.assignee_type === "agent") ? (
<ActorAvatar
type={issue.assignee_type}
id={issue.assignee_id}
size={20}
/>
) : null}
</View>
</Pressable>
);
}

View File

@@ -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 (
<View>
<View className="flex-row items-center justify-between px-4 py-2 bg-background">
<Text className="text-xs uppercase tracking-wider text-muted-foreground font-medium">
Resources
</Text>
<Pressable onPress={onAdd} className="px-2 py-1 active:bg-secondary rounded">
<Text className="text-xs text-brand">Add</Text>
</Pressable>
</View>
{isLoading ? (
<View className="px-4 py-4 items-center">
<ActivityIndicator size="small" />
</View>
) : !resources || resources.length === 0 ? (
<View className="px-4 py-3">
<Text className="text-sm text-muted-foreground/70">
No resources attached.
</Text>
</View>
) : (
resources.map((resource) => (
<ResourceRow
key={resource.id}
resource={resource}
onPress={() => onOpen(resource)}
onLongPress={() => onLongPress(resource)}
/>
))
)}
</View>
);
}
function ResourceRow({
resource,
onPress,
onLongPress,
}: {
resource: ProjectResource;
onPress: () => void;
onLongPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
onLongPress={onLongPress}
delayLongPress={400}
className="flex-row items-center gap-3 px-4 py-2.5 active:bg-secondary border-t border-border"
>
<Ionicons
name={iconFor(resource.resource_type)}
size={16}
color="#71717a"
/>
<View className="flex-1">
<Text className="text-sm text-foreground" numberOfLines={1}>
{resource.label ?? describeResource(resource)}
</Text>
{resource.label ? (
<Text className="text-xs text-muted-foreground" numberOfLines={1}>
{describeResource(resource)}
</Text>
) : null}
</View>
</Pressable>
);
}
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;
}

View File

@@ -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 (
<Pressable onPress={onPress} className="active:bg-secondary px-4 py-3">
<View className="flex-row items-start gap-3">
<View className="pt-0.5">
<ProjectIcon icon={project.icon} size="lg" />
</View>
<View className="flex-1 gap-1">
<Text
className="text-base text-foreground font-medium"
numberOfLines={1}
>
{project.title}
</Text>
<View className="flex-row items-center gap-3">
<View className="flex-row items-center gap-1.5">
<ProjectStatusIcon status={project.status} size={12} />
<Text className="text-xs text-muted-foreground">
{projectStatusLabel(project.status)}
</Text>
</View>
{project.priority !== "none" ? (
<View className="flex-row items-center gap-1.5">
<ProjectPriorityIcon priority={project.priority} size={12} />
<Text className="text-xs text-muted-foreground">
{projectPriorityLabel(project.priority)}
</Text>
</View>
) : null}
</View>
</View>
<View className="items-end gap-1">
{showCount ? (
<Text className="text-xs text-muted-foreground tabular-nums">
{project.done_count}/{totalIssues}
</Text>
) : (
<Text className="text-xs text-muted-foreground/60"></Text>
)}
<Text className="text-[11px] text-muted-foreground/70">
{timeAgo(project.updated_at)}
</Text>
</View>
</View>
</Pressable>
);
}

View File

@@ -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<ProjectPriority, string> = {
urgent: "#dc2626",
high: "#eab308",
medium: "#eab308",
low: "#3b82f6",
none: "#71717a",
};
function colorFor(priority: string): string {
return (COLOR as Record<string, string>)[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 (
<Svg width={size} height={size} viewBox="0 0 16 16">
<Line
x1={3}
y1={8}
x2={13}
y2={8}
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
/>
</Svg>
);
}
return (
<Svg width={size} height={size} viewBox="0 0 16 16">
{[0, 1, 2, 3].map((i) => {
const y = 12 - (i + 1) * 3;
const h = (i + 1) * 3;
return (
<Rect
key={i}
x={1 + i * 4}
y={y}
width={3}
height={h}
rx={0.5}
fill={color}
opacity={i < filled ? 1 : 0.2}
/>
);
})}
</Svg>
);
}

View File

@@ -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 (
<>
<Circle
cx={CX}
cy={CY}
r={OUTER_R}
fill="none"
stroke={color}
strokeWidth={1.5}
/>
{children}
</>
);
}
function PauseBars({ color }: { color: string }) {
return (
<>
<Line
x1={5.5}
y1={4.5}
x2={5.5}
y2={9.5}
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
/>
<Line
x1={8.5}
y1={4.5}
x2={8.5}
y2={9.5}
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
/>
</>
);
}
function CancelX({ color }: { color: string }) {
return (
<Path
d="M5 5 L9 9 M9 5 L5 9"
fill="none"
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
/>
);
}
function DoneCheck() {
return (
<Path
d="M10.951 4.24896C11.283 4.58091 11.283 5.11909 10.951 5.45104L5.95104 10.451C5.61909 10.783 5.0809 10.783 4.74896 10.451L2.74896 8.45104C2.41701 8.11909 2.41701 7.5809 2.74896 7.24896C3.0809 6.91701 3.61909 6.91701 3.95104 7.24896L5.35 8.64792L9.74896 4.24896C10.0809 3.91701 10.6191 3.91701 10.951 4.24896Z"
fill="#ffffff"
/>
);
}
export function ProjectStatusIcon({
status,
size = 16,
}: {
status: ProjectStatus | string;
size?: number;
}) {
const color = projectStatusColor(status);
return (
<Svg width={size} height={size} viewBox="0 0 14 14">
{status === "planned" ? (
<Ring color={color} />
) : status === "in_progress" ? (
<Ring color={color}>
<Path d={piePath(0.5)} fill={color} />
</Ring>
) : status === "paused" ? (
<Ring color={color}>
<PauseBars color={color} />
</Ring>
) : status === "completed" ? (
<>
<Circle cx={CX} cy={CY} r={OUTER_R} fill={color} />
<DoneCheck />
</>
) : status === "cancelled" ? (
<Ring color={color}>
<CancelX color={color} />
</Ring>
) : (
// Unknown server enum value — render the planned ring so the row
// still reads as "a project" rather than crashing or going blank.
<Ring color={color} />
)}
</Svg>
);
}

View File

@@ -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<Project> {
const raw = await this.fetch<unknown>(`/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<Project> {
return this.fetch<Project>("/api/projects", {
method: "POST",
body: JSON.stringify(body),
});
}
async updateProject(
id: string,
body: UpdateProjectRequest,
): Promise<Project> {
return this.fetch<Project>(`/api/projects/${id}`, {
method: "PUT",
body: JSON.stringify(body),
});
}
async deleteProject(id: string): Promise<void> {
await this.fetch<void>(`/api/projects/${id}`, { method: "DELETE" });
}
// --- Project resources ---
async listProjectResources(
projectId: string,
opts?: { signal?: AbortSignal },
): Promise<ListProjectResourcesResponse> {
const raw = await this.fetch<unknown>(
`/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<ProjectResource> {
return this.fetch<ProjectResource>(
`/api/projects/${projectId}/resources`,
{
method: "POST",
body: JSON.stringify(body),
},
);
}
async deleteProjectResource(
projectId: string,
resourceId: string,
): Promise<void> {
await this.fetch<void>(
`/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

View File

@@ -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<Project>(projectKeys.detail(wsId, project.id), project);
qc.setQueryData<Project[]>(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<Project>(detailKey);
const prevList = qc.getQueryData<Project[]>(listKey);
if (prevDetail) {
qc.setQueryData<Project>(detailKey, { ...prevDetail, ...patch });
}
qc.setQueryData<Project[]>(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<Project>(projectKeys.detail(wsId, projectId), server);
qc.setQueryData<Project[]>(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<Project[]>(listKey);
qc.setQueryData<Project[]>(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<ProjectResource[]>(
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<Project>(
projectKeys.detail(wsId, projectId),
(old) => (old ? bumpCount(old) : old),
);
qc.setQueryData<Project[]>(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<ProjectResource[]>(key);
qc.setQueryData<ProjectResource[]>(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<Project>(
projectKeys.detail(wsId, projectId),
(old) => (old ? dropCount(old) : old),
);
qc.setQueryData<Project[]>(projectKeys.list(wsId), (old) =>
old
? old.map((p) => (p.id === projectId ? dropCount(p) : p))
: old,
);
},
});
}

View File

@@ -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.

View File

@@ -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<Project> & { id: string },
) {
qc.setQueryData<Project[]>(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<Project[]>(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<Project[]>(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<Project>(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) });
}

View File

@@ -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<Issue[]>(issueListKey) ?? []).some(
(i) => i.id === issue.id,
);
const nowInProject = issue.project_id === projectId;
if (!wasInList && !nowInProject) return;
qc.setQueryData<Issue[]>(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<Issue[]>(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]);
}

View File

@@ -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]);
}

View File

@@ -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<SendChatMessageResponse> =
}).loose();
// Helpers re-exported for ergonomic single-import at the call site.
export type { Label, Project };
export type { Label, Project, ProjectResource };

View File

@@ -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<ProjectStatus, string> = {
planned: "Planned",
in_progress: "In Progress",
paused: "Paused",
completed: "Completed",
cancelled: "Cancelled",
};
export const PROJECT_PRIORITY_LABEL: Record<ProjectPriority, string> = {
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<ProjectStatus, string> = {
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<ProjectPriority, number> = {
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<string, string>)[value] ?? value;
}
export function projectPriorityLabel(value: string): string {
return (PROJECT_PRIORITY_LABEL as Record<string, string>)[value] ?? value;
}
export function projectStatusColor(value: string): string {
return (PROJECT_STATUS_COLOR as Record<string, string>)[value] ?? "#71717a";
}
export function projectPriorityBars(value: string): number {
return (PROJECT_PRIORITY_BARS as Record<string, number>)[value] ?? 0;
}