Files
multica/apps/mobile/data/api.ts
Naiyuan Qing 95979515b2 feat(mobile): issue detail — comment-as-modal route, hex/Pressable cleanup, API helpers
Comment composer redesign (user feedback: inline always-on was clunky,
keyboard avoidance bad, no room for @mention suggestion bar). The bottom
of issue/[id].tsx is now a single <Button>Comment</Button>; tap pushes
the new issue/[id]/new-comment modal — full screen for typing,
AutosizeTextArea + MentionSuggestionBar + toolbar. Reply path goes
through the same modal with parent / parentName route params, so
"Reply" on a comment long-press just pushes the modal in reply mode.

Comment-card long-press no longer competes with iOS native text
selection: wrapped <Markdown> in a View with userSelect:'none' so the
press only triggers the action sheet. Users can still copy the full
comment body via the existing "Copy text" entry.

issue/[id].tsx headerRight 3-dot menu switches from a hand-drawn
Pressable + Ionicons (hardcoded #0a84ff/#71717a) to <IconButton>. Same
hex cleanup applied to:
  - agent-activity-row.tsx (2× #a1a1aa → THEME.mutedForeground)
  - activity-row.tsx (MUTED constant deleted; SVG glyph takes stroke prop)
  - comment-card.tsx BRAND_RING/BRAND_WASH rgba constants gone — animated
    overlays now use NativeWind border-brand/50 + bg-brand/5 classes,
    opacity stays the only animated channel.

API layer: 5 issue GET methods migrated to fetchValidated (getIssue,
listTimeline, listAttachments, listActiveTasksForIssue, listTasksByIssue).
Write endpoints stay on raw this.fetch per the existing mobile convention
— migrating writes needs new zod schemas, defer to a follow-up PR.

comment-composer.tsx deleted: orphan after the modal swap. CommentActionSheet
is kept as-is — it has the quick-react emoji row (the only "add reaction"
entry for comments) and already follows the correct Lesson 6 short-action
card pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:12:09 +08:00

1110 lines
36 KiB
TypeScript

/**
* Mobile-owned fetch wrapper. Mirrors the surface area of
* packages/core/api/client.ts that mobile actually uses, but lives in
* apps/mobile/ so we control retry/timeout/error handling independently.
*
* Types are imported via `import type` from @multica/core/types — zero
* runtime coupling. Zod schemas + fallbacks are imported from
* @multica/core/api/schemas (pure data, on the mobile sharing whitelist).
*
* Design checklist (apps/mobile/CLAUDE.md "Lessons → ApiClient capability list"):
* 1. Zod parseWithFallback for endpoints with schemas (drift defense)
* 2. onUnauthorized callback on 401 (auto sign-out, avoids retry loops)
* 3. X-Request-ID per request + structured logger (debug + tracing)
* 4. Bearer auth + X-Workspace-Slug — NOT cookie auth (no CSRF, no credentials)
*/
import type {
Agent,
AgentTask,
Attachment,
ChatMessage,
ChatPendingTask,
ChatSession,
Comment,
CreateIssueRequest,
CreateProjectRequest,
CreateProjectResourceRequest,
InboxItem,
Issue,
IssueLabelsResponse,
IssueReaction,
ListIssuesParams,
ListIssuesResponse,
ListLabelsResponse,
ListProjectResourcesResponse,
ListProjectsResponse,
MemberWithUser,
Project,
ProjectResource,
Reaction,
RuntimeDevice,
SearchIssuesResponse,
SearchProjectsResponse,
SendChatMessageResponse,
Squad,
NotificationPreferenceResponse,
NotificationPreferences,
TimelineEntry,
UpdateIssueRequest,
UpdateMeRequest,
UpdateProjectRequest,
User,
Workspace,
} from "@multica/core/types";
import {
EMPTY_LIST_ISSUES_RESPONSE,
EMPTY_TIMELINE_ENTRIES,
IssueSchema,
ListIssuesResponseSchema,
TimelineEntriesSchema,
} from "@multica/core/api/schemas";
import {
ActiveTasksResponseSchema,
AgentListSchema,
AgentTaskListSchema,
AttachmentListSchema,
AttachmentSchema,
ChatMessageListSchema,
ChatPendingTaskSchema,
ChatSessionListSchema,
ChatSessionSchema,
EMPTY_ACTIVE_TASKS_RESPONSE,
EMPTY_AGENT_LIST,
EMPTY_AGENT_TASK_LIST,
EMPTY_ATTACHMENT_LIST,
EMPTY_CHAT_MESSAGE_LIST,
EMPTY_CHAT_PENDING_TASK,
EMPTY_CHAT_SESSION_LIST,
EMPTY_INBOX_LIST,
EMPTY_ISSUE_FALLBACK,
EMPTY_LIST_LABELS_RESPONSE,
EMPTY_LIST_PROJECT_RESOURCES_RESPONSE,
EMPTY_LIST_PROJECTS_RESPONSE,
EMPTY_MEMBER_LIST,
EMPTY_NOTIFICATION_PREFERENCES,
EMPTY_PROJECT,
EMPTY_RUNTIME_LIST,
EMPTY_SEARCH_ISSUES_RESPONSE,
EMPTY_SEARCH_PROJECTS_RESPONSE,
EMPTY_SQUAD_LIST,
EMPTY_USER,
EMPTY_WORKSPACE_LIST,
InboxListSchema,
NotificationPreferenceResponseSchema,
ListLabelsResponseSchema,
ListProjectResourcesResponseSchema,
ListProjectsResponseSchema,
MemberListSchema,
ProjectSchema,
RuntimeListSchema,
SearchIssuesResponseSchema,
SearchProjectsResponseSchema,
SendChatMessageResponseSchema,
SquadListSchema,
UserSchema,
WorkspaceListSchema,
} from "./schemas";
import type { ZodType } from "zod";
import { getCurrentSlug } from "./workspace-store";
import { parseWithFallback } from "@/lib/parse-response";
import { createRequestId } from "@/lib/request-id";
const API_URL = process.env.EXPO_PUBLIC_API_URL;
if (!API_URL) {
throw new Error(
"EXPO_PUBLIC_API_URL is not set. Add it to apps/mobile/.env.development.local " +
"(see apps/mobile/.env.staging for an example).",
);
}
export interface LoginResponse {
token: string;
user: User;
}
/** Mobile file payload for `uploadFile`. RN doesn't have a browser `File`
* object; the fetch `FormData` polyfill accepts `{ uri, name, type }`
* directly and streams from disk. expo-image-picker / expo-document-picker
* return assets that map straight onto this shape. */
export interface FileAsset {
uri: string;
name: string;
type: string;
}
/** Web mirrors this from `packages/core/constants/upload.ts`. Mobile keeps
* its own copy per the `mirror, don't import` rule in apps/mobile/CLAUDE.md. */
const MAX_FILE_SIZE = 100 * 1024 * 1024;
/** Hard ceiling for every HTTP request. Mobile-specific because iOS may
* suspend a backgrounded network task without ever resolving/rejecting
* the JS-side fetch promise (facebook/react-native#35384). Without this
* timeout, a refetch fired after returning to foreground can leave the
* query stuck in `isRefetching` state forever (visible as the
* pull-to-refresh spinner never going away). 30s is generous for any
* reasonable Multica payload size on cellular. */
const FETCH_TIMEOUT_MS = 30_000;
export class ApiError extends Error {
readonly status: number;
readonly body?: unknown;
constructor(message: string, status: number, body?: unknown) {
super(message);
this.name = "ApiError";
this.status = status;
this.body = body;
}
}
export interface ApiClientOptions {
/** Called once when the server returns 401. The platform layer wires this
* to clear the token + navigate to /login so a stale token doesn't keep
* every subsequent request looping on 401. */
onUnauthorized?: () => void;
}
class ApiClient {
private token: string | null = null;
private options: ApiClientOptions = {};
setToken(token: string | null) {
this.token = token;
}
setOptions(options: ApiClientOptions) {
this.options = { ...this.options, ...options };
}
private async fetch<T>(
path: string,
init: RequestInit & { signal?: AbortSignal } = {},
): Promise<T> {
const rid = createRequestId();
const start = Date.now();
const method = init.method ?? "GET";
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Client-Platform": "mobile",
"X-Client-OS": "ios",
"X-Client-Version": "0.1.0",
"X-Request-ID": rid,
...((init.headers as Record<string, string>) ?? {}),
};
if (this.token) {
headers["Authorization"] = `Bearer ${this.token}`;
}
// Backend middleware (server/internal/middleware/workspace.go) resolves
// slug → ws UUID and gates membership. Mirrors packages/core/api/client.ts.
const slug = getCurrentSlug();
if (slug) {
headers["X-Workspace-Slug"] = slug;
}
// Timeout + caller-signal forwarding.
//
// Hermes does NOT support AbortSignal.timeout() or AbortSignal.any() —
// see facebook/react-native#42042 and livekit#4014. So we manually
// compose a single controller that aborts on:
// (a) caller-side signal (TQ cancelling a stale/inactive query, etc),
// (b) 30s timeout (defends against iOS suspending the network task
// silently during background — fetch() then never resolves;
// facebook/react-native#35384). Without this, a refetch
// triggered by WS reconnect can leave the FlatList pull-to-refresh
// spinner stuck on the screen indefinitely.
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort(new Error(`request timed out after ${FETCH_TIMEOUT_MS}ms`));
}, FETCH_TIMEOUT_MS);
const callerSignal = init.signal;
const onCallerAbort = () => controller.abort(callerSignal?.reason);
if (callerSignal) {
if (callerSignal.aborted) controller.abort(callerSignal.reason);
else callerSignal.addEventListener("abort", onCallerAbort);
}
console.log(`[api] → ${method} ${path}`, { rid });
let res: Response;
try {
res = await fetch(`${API_URL}${path}`, {
...init,
signal: controller.signal,
headers,
});
} catch (err) {
clearTimeout(timeoutId);
callerSignal?.removeEventListener("abort", onCallerAbort);
// Re-throw with a clearer message if this was our own timeout abort.
if (
err instanceof Error &&
err.name === "AbortError" &&
!callerSignal?.aborted
) {
const duration = Date.now() - start;
console.warn(`[api] ← TIMEOUT ${path}`, {
rid,
duration: `${duration}ms`,
});
throw new ApiError(
`Request timed out after ${FETCH_TIMEOUT_MS}ms`,
0,
undefined,
);
}
throw err;
}
clearTimeout(timeoutId);
callerSignal?.removeEventListener("abort", onCallerAbort);
const duration = Date.now() - start;
if (!res.ok) {
// 401 sign-out hook: invoke once, let the platform layer (auth-store)
// clear the token + navigate. Subsequent requests in flight will also
// 401 and re-enter here, so the callback must be idempotent.
if (res.status === 401) {
this.options.onUnauthorized?.();
}
let body: unknown;
try {
body = await res.json();
} catch {
body = undefined;
}
const message =
(body && typeof body === "object" && "message" in body
? String((body as { message: unknown }).message)
: null) ?? `${res.status} ${res.statusText}`;
const level = res.status === 404 ? "warn" : "error";
console[level](`[api] ← ${res.status} ${path}`, {
rid,
duration: `${duration}ms`,
error: message,
});
throw new ApiError(message, res.status, body);
}
console.log(`[api] ← ${res.status} ${path}`, {
rid,
duration: `${duration}ms`,
});
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
/**
* Read-side helper: GET + zod parse + fallback in one call. Collapses
* the boilerplate that every list/detail endpoint repeats:
*
* const raw = await this.fetch<unknown>(path, { signal: opts?.signal });
* return parseWithFallback(raw, Schema, FALLBACK, { endpoint: "name" });
*
* Always uses GET (no method arg) — write endpoints that need parsing
* still go through `this.fetch` + `parseWithFallback` directly because
* they carry a body and care about method semantics. Use
* `fetchValidatedWith` for those (PATCH / PUT / POST).
*
* The `endpoint` label defaults to the request path — override only when
* the path has dynamic segments and you want stable telemetry labels.
*/
private async fetchValidated<T>(
path: string,
schema: ZodType,
fallback: T,
opts?: { signal?: AbortSignal; endpoint?: string },
): Promise<T> {
const raw = await this.fetch<unknown>(path, { signal: opts?.signal });
return parseWithFallback(raw, schema, fallback, {
endpoint: opts?.endpoint ?? path,
});
}
/** Same as fetchValidated but supports any HTTP method + body. Used by
* PATCH/PUT/POST endpoints whose response we still want to validate
* (e.g. updateMe returns User, updateNotificationPreferences returns
* NotificationPreferenceResponse). */
private async fetchValidatedWith<T>(
path: string,
schema: ZodType,
fallback: T,
init: RequestInit,
opts?: { signal?: AbortSignal; endpoint?: string },
): Promise<T> {
// `opts.signal` wins if both are passed, but absent opts.signal does
// NOT clear init.signal — important because forgetting `?? init.signal`
// would silently strip a caller's abort signal when they used the
// RequestInit shape but no opts.
const raw = await this.fetch<unknown>(path, {
...init,
signal: opts?.signal ?? init.signal ?? undefined,
});
return parseWithFallback(raw, schema, fallback, {
endpoint: opts?.endpoint ?? `${init.method ?? "GET"} ${path}`,
});
}
// --- Auth ---
async sendCode(email: string): Promise<void> {
await this.fetch<void>("/auth/send-code", {
method: "POST",
body: JSON.stringify({ email }),
});
}
async verifyCode(email: string, code: string): Promise<LoginResponse> {
return this.fetch<LoginResponse>("/auth/verify-code", {
method: "POST",
body: JSON.stringify({ email, code }),
});
}
async getMe(opts?: { signal?: AbortSignal }): Promise<User> {
return this.fetchValidated(
"/api/me",
UserSchema,
EMPTY_USER,
{ ...opts, endpoint: "getMe" },
);
}
// PATCH /api/me — name, avatar_url, language. Server returns the updated
// user; we parse so a partial drift doesn't bleed into the auth store.
async updateMe(data: UpdateMeRequest): Promise<User> {
return this.fetchValidatedWith(
"/api/me",
UserSchema,
EMPTY_USER,
{ method: "PATCH", body: JSON.stringify(data) },
{ endpoint: "updateMe" },
);
}
// --- Notification preferences ---
async getNotificationPreferences(
opts?: { signal?: AbortSignal },
): Promise<NotificationPreferenceResponse> {
return this.fetchValidated(
"/api/notification-preferences",
NotificationPreferenceResponseSchema,
EMPTY_NOTIFICATION_PREFERENCES,
{ ...opts, endpoint: "getNotificationPreferences" },
);
}
async updateNotificationPreferences(
preferences: NotificationPreferences,
): Promise<NotificationPreferenceResponse> {
return this.fetchValidatedWith(
"/api/notification-preferences",
NotificationPreferenceResponseSchema,
EMPTY_NOTIFICATION_PREFERENCES,
{ method: "PUT", body: JSON.stringify({ preferences }) },
{ endpoint: "updateNotificationPreferences" },
);
}
// --- Workspaces ---
async listWorkspaces(opts?: {
signal?: AbortSignal;
}): Promise<Workspace[]> {
const raw = await this.fetch<unknown>("/api/workspaces", {
signal: opts?.signal,
});
return parseWithFallback(raw, WorkspaceListSchema, EMPTY_WORKSPACE_LIST, {
endpoint: "listWorkspaces",
});
}
// --- Inbox ---
async listInbox(opts?: { signal?: AbortSignal }): Promise<InboxItem[]> {
const raw = await this.fetch<unknown>("/api/inbox", {
signal: opts?.signal,
});
return parseWithFallback(raw, InboxListSchema, EMPTY_INBOX_LIST, {
endpoint: "listInbox",
});
}
async markInboxRead(id: string): Promise<InboxItem> {
return this.fetch<InboxItem>(`/api/inbox/${id}/read`, { method: "POST" });
}
// Archive endpoints — write surface. Match web's surface in
// packages/core/api/client.ts:981-1003. No parseWithFallback (mirrors
// markInboxRead above and the project write endpoints): a malformed
// archive response should surface naturally so the optimistic patch
// rolls back.
async archiveInbox(id: string): Promise<InboxItem> {
return this.fetch<InboxItem>(`/api/inbox/${id}/archive`, { method: "POST" });
}
async markAllInboxRead(): Promise<{ count: number }> {
return this.fetch<{ count: number }>("/api/inbox/mark-all-read", {
method: "POST",
});
}
async archiveAllInbox(): Promise<{ count: number }> {
return this.fetch<{ count: number }>("/api/inbox/archive-all", {
method: "POST",
});
}
async archiveAllReadInbox(): Promise<{ count: number }> {
return this.fetch<{ count: number }>("/api/inbox/archive-all-read", {
method: "POST",
});
}
async archiveCompletedInbox(): Promise<{ count: number }> {
return this.fetch<{ count: number }>("/api/inbox/archive-completed", {
method: "POST",
});
}
// --- Members & Agents (for actor name/avatar lookup) ---
async listMembers(
workspaceId: string,
opts?: { signal?: AbortSignal },
): Promise<MemberWithUser[]> {
const raw = await this.fetch<unknown>(
`/api/workspaces/${workspaceId}/members`,
{ signal: opts?.signal },
);
return parseWithFallback(raw, MemberListSchema, EMPTY_MEMBER_LIST, {
endpoint: "listMembers",
});
}
async listAgents(opts?: { signal?: AbortSignal }): Promise<Agent[]> {
const raw = await this.fetch<unknown>("/api/agents", {
signal: opts?.signal,
});
return parseWithFallback(raw, AgentListSchema, EMPTY_AGENT_LIST, {
endpoint: "listAgents",
});
}
// Workspace runtimes — feeds the presence dot's availability dimension
// (runtime.status + last_seen_at). Backend route registered in
// server/cmd/server/router.go:514 (GET /api/runtimes).
async listRuntimes(opts?: { signal?: AbortSignal }): Promise<RuntimeDevice[]> {
const raw = await this.fetch<unknown>("/api/runtimes", {
signal: opts?.signal,
});
return parseWithFallback(raw, RuntimeListSchema, EMPTY_RUNTIME_LIST, {
endpoint: "listRuntimes",
});
}
// Workspace-wide active agent tasks + each agent's most recent terminal —
// feeds the workload dimension of presence (currently unused in the mobile
// dot; reserved for the P1 long-press peek sheet). Listed here now so the
// realtime invalidation path can be wired in one PR. Backend route at
// server/cmd/server/router.go:539 (GET /api/agent-task-snapshot).
async listAgentTaskSnapshot(
opts?: { signal?: AbortSignal },
): Promise<AgentTask[]> {
const raw = await this.fetch<unknown>("/api/agent-task-snapshot", {
signal: opts?.signal,
});
return parseWithFallback(raw, AgentTaskListSchema, EMPTY_AGENT_TASK_LIST, {
endpoint: "listAgentTaskSnapshot",
});
}
async listSquads(opts?: { signal?: AbortSignal }): Promise<Squad[]> {
const raw = await this.fetch<unknown>("/api/squads", {
signal: opts?.signal,
});
return parseWithFallback(raw, SquadListSchema, EMPTY_SQUAD_LIST, {
endpoint: "listSquads",
});
}
// --- Issues ---
async listIssues(
params: ListIssuesParams = {},
opts?: { signal?: AbortSignal },
): Promise<ListIssuesResponse> {
const search = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v == null) continue;
if (Array.isArray(v)) {
// Backend parses comma-separated lists (server/internal/handler/issue.go
// uses strings.Split on a single query value). Match web's serialization
// in packages/core/api/client.ts:407 — repeated keys would silently
// collapse to the first value only.
if (v.length > 0) search.set(k, v.map(String).join(","));
} else {
search.set(k, String(v));
}
}
const qs = search.toString();
const raw = await this.fetch<unknown>(
`/api/issues${qs ? `?${qs}` : ""}`,
{ signal: opts?.signal },
);
return parseWithFallback(raw, ListIssuesResponseSchema, EMPTY_LIST_ISSUES_RESPONSE, {
endpoint: "GET /api/issues",
});
}
/** Workspace-wide issue search. Backend `GET /api/issues/search` with
* workspace resolved by the `X-Workspace-Slug` middleware (same as
* `listIssues`). Caller passes its own `AbortController.signal` so the
* search modal can cancel an in-flight request when the user types
* again — see app/(app)/[workspace]/search.tsx. */
async searchIssues(
params: { q: string; limit?: number; include_closed?: boolean; offset?: number },
opts?: { signal?: AbortSignal },
): Promise<SearchIssuesResponse> {
const search = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v == null) continue;
search.set(k, String(v));
}
const raw = await this.fetch<unknown>(
`/api/issues/search?${search.toString()}`,
{ signal: opts?.signal },
);
return parseWithFallback(raw, SearchIssuesResponseSchema, EMPTY_SEARCH_ISSUES_RESPONSE, {
endpoint: "GET /api/issues/search",
});
}
async getIssue(
id: string,
opts?: { signal?: AbortSignal },
): Promise<Issue> {
return this.fetchValidated(
`/api/issues/${id}`,
IssueSchema,
EMPTY_ISSUE_FALLBACK,
{ ...opts, endpoint: "getIssue" },
);
}
// Write endpoint — mirrors POST /api/issues
// (server/cmd/server/router.go:320, server/internal/handler/issue.go
// CreateIssue). Mobile sends only the fields the form fills in; backend
// applies its own defaults for anything omitted.
async createIssue(body: CreateIssueRequest): Promise<Issue> {
return this.fetch<Issue>("/api/issues", {
method: "POST",
body: JSON.stringify(body),
});
}
// Timeline returns the full ASC entry list in one shot — server-side
// pagination was dropped in #2322 (p99 ~30 entries per issue, cursors
// were pure overhead and split reply threads at page boundaries).
// Call WITHOUT pagination params: the legacy `limit/before/after/around`
// path returns the old wrapped shape for back-compat, which mobile must
// NOT trigger. See server/internal/handler/activity.go:60-69.
async listTimeline(
issueId: string,
opts?: { signal?: AbortSignal },
): Promise<TimelineEntry[]> {
return this.fetchValidated(
`/api/issues/${issueId}/timeline`,
TimelineEntriesSchema,
EMPTY_TIMELINE_ENTRIES,
{ ...opts, endpoint: "GET /api/issues/:id/timeline" },
);
}
// GET /api/issues/:id/attachments — list of file attachments hooked to
// the issue (or its comments). Mobile uses this to resolve `mc://file/<id>`
// markdown image URIs to their `download_url` HTTPS endpoint; without it,
// iOS image loader doesn't understand the mc: scheme and renders broken.
async listAttachments(
issueId: string,
opts?: { signal?: AbortSignal },
): Promise<Attachment[]> {
return this.fetchValidated(
`/api/issues/${issueId}/attachments`,
AttachmentListSchema,
EMPTY_ATTACHMENT_LIST,
{ ...opts, endpoint: "GET /api/issues/:id/attachments" },
);
}
// Active tasks for an issue (status in queued/dispatched/running). Returns
// the inner `tasks` array directly — handler wraps it in `{ tasks: [] }`
// (server/internal/handler/daemon.go:1866) so the response object survives
// future field additions without breaking the cache shape.
async listActiveTasksForIssue(
issueId: string,
opts?: { signal?: AbortSignal },
): Promise<AgentTask[]> {
const parsed = await this.fetchValidated(
`/api/issues/${issueId}/active-task`,
ActiveTasksResponseSchema,
EMPTY_ACTIVE_TASKS_RESPONSE,
{ ...opts, endpoint: "GET /api/issues/:id/active-task" },
);
return parsed.tasks;
}
// All tasks (any status) for an issue — drives the "Runs" history section.
// Path is `/task-runs` (server/cmd/server/router.go:353), NOT `/tasks` —
// the latter doesn't exist on this scope.
async listTasksByIssue(
issueId: string,
opts?: { signal?: AbortSignal },
): Promise<AgentTask[]> {
return this.fetchValidated(
`/api/issues/${issueId}/task-runs`,
AgentTaskListSchema,
EMPTY_AGENT_TASK_LIST,
{ ...opts, endpoint: "GET /api/issues/:id/task-runs" },
);
}
async createComment(
issueId: string,
content: string,
parentId?: string,
): Promise<Comment> {
// Body shape mirrors backend `CreateCommentRequest`
// (server/internal/handler/comment.go:165). `parent_id` is sent only
// when present so top-level comments don't carry an explicit null.
return this.fetch<Comment>(`/api/issues/${issueId}/comments`, {
method: "POST",
body: JSON.stringify({
content,
...(parentId ? { parent_id: parentId } : {}),
}),
});
}
// --- Reactions ---
// Comment reactions: POST/DELETE /api/comments/{id}/reactions
// Issue reactions: POST/DELETE /api/issues/{id}/reactions
// Mirror surface from packages/core/api/client.ts:541-573.
async addReaction(commentId: string, emoji: string): Promise<Reaction> {
return this.fetch<Reaction>(`/api/comments/${commentId}/reactions`, {
method: "POST",
body: JSON.stringify({ emoji }),
});
}
async removeReaction(commentId: string, emoji: string): Promise<void> {
await this.fetch<void>(`/api/comments/${commentId}/reactions`, {
method: "DELETE",
body: JSON.stringify({ emoji }),
});
}
async addIssueReaction(
issueId: string,
emoji: string,
): Promise<IssueReaction> {
return this.fetch<IssueReaction>(`/api/issues/${issueId}/reactions`, {
method: "POST",
body: JSON.stringify({ emoji }),
});
}
async removeIssueReaction(issueId: string, emoji: string): Promise<void> {
await this.fetch<void>(`/api/issues/${issueId}/reactions`, {
method: "DELETE",
body: JSON.stringify({ emoji }),
});
}
// --- Issue update ---
// Write endpoint — the mutation surface handles errors via rollback, so
// we let bad responses surface naturally (no parseWithFallback).
// Method is PUT to match backend router (server/cmd/server/router.go:327)
// and web client (packages/core/api/client.ts:465).
async updateIssue(id: string, body: UpdateIssueRequest): Promise<Issue> {
return this.fetch<Issue>(`/api/issues/${id}`, {
method: "PUT",
body: JSON.stringify(body),
});
}
// Backend returns 204 No Content on success
// (server/internal/handler/issue.go DeleteIssue). this.fetch already
// short-circuits 204 → undefined (api.ts:270), so no body parsing needed.
async deleteIssue(id: string): Promise<void> {
await this.fetch<void>(`/api/issues/${id}`, { method: "DELETE" });
}
// --- Labels ---
async listLabels(opts?: {
signal?: AbortSignal;
}): Promise<ListLabelsResponse> {
const raw = await this.fetch<unknown>("/api/labels", {
signal: opts?.signal,
});
return parseWithFallback(
raw,
ListLabelsResponseSchema,
EMPTY_LIST_LABELS_RESPONSE,
{ endpoint: "GET /api/labels" },
);
}
async attachLabel(
issueId: string,
labelId: string,
): Promise<IssueLabelsResponse> {
return this.fetch<IssueLabelsResponse>(
`/api/issues/${issueId}/labels`,
{
method: "POST",
body: JSON.stringify({ label_id: labelId }),
},
);
}
async detachLabel(
issueId: string,
labelId: string,
): Promise<IssueLabelsResponse> {
return this.fetch<IssueLabelsResponse>(
`/api/issues/${issueId}/labels/${labelId}`,
{ method: "DELETE" },
);
}
// --- Projects ---
async listProjects(opts?: {
signal?: AbortSignal;
}): Promise<ListProjectsResponse> {
const raw = await this.fetch<unknown>("/api/projects", {
signal: opts?.signal,
});
return parseWithFallback(
raw,
ListProjectsResponseSchema,
EMPTY_LIST_PROJECTS_RESPONSE,
{ endpoint: "GET /api/projects" },
);
}
/** Workspace-wide project search. See `searchIssues` for the signal
* contract. */
async searchProjects(
params: { q: string; limit?: number; include_closed?: boolean; offset?: number },
opts?: { signal?: AbortSignal },
): Promise<SearchProjectsResponse> {
const search = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v == null) continue;
search.set(k, String(v));
}
const raw = await this.fetch<unknown>(
`/api/projects/search?${search.toString()}`,
{ signal: opts?.signal },
);
return parseWithFallback(
raw,
SearchProjectsResponseSchema,
EMPTY_SEARCH_PROJECTS_RESPONSE,
{ endpoint: "GET /api/projects/search" },
);
}
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
// list in /Users/qingnaiyuan/.claude/plans/plan-velvety-puddle.md.
async listChatSessions(
opts?: { signal?: AbortSignal },
): Promise<ChatSession[]> {
const raw = await this.fetch<unknown>("/api/chat/sessions", {
signal: opts?.signal,
});
return parseWithFallback(
raw,
ChatSessionListSchema,
EMPTY_CHAT_SESSION_LIST,
{ endpoint: "GET /api/chat/sessions" },
);
}
async createChatSession(
data: { agent_id: string; title?: string },
): Promise<ChatSession> {
// Strict parse — a malformed create response derails the optimistic
// burst (we need the new session id to seed caches). Fallback would
// be worse than the throw.
const raw = await this.fetch<unknown>("/api/chat/sessions", {
method: "POST",
body: JSON.stringify(data),
});
const parsed = ChatSessionSchema.safeParse(raw);
if (!parsed.success) {
console.error("[api] ← shape mismatch POST /api/chat/sessions", {
issues: parsed.error.issues,
});
throw new ApiError("Create chat session response invalid", 0, raw);
}
return parsed.data;
}
async deleteChatSession(id: string): Promise<void> {
await this.fetch<void>(`/api/chat/sessions/${id}`, { method: "DELETE" });
}
async listChatMessages(
sessionId: string,
opts?: { signal?: AbortSignal },
): Promise<ChatMessage[]> {
const raw = await this.fetch<unknown>(
`/api/chat/sessions/${sessionId}/messages`,
{ signal: opts?.signal },
);
return parseWithFallback(
raw,
ChatMessageListSchema,
EMPTY_CHAT_MESSAGE_LIST,
{ endpoint: "GET /api/chat/sessions/:id/messages" },
);
}
async sendChatMessage(
sessionId: string,
content: string,
): Promise<SendChatMessageResponse> {
// Strict parse — we need task_id + created_at to anchor the optimistic
// StatusPill. Fallback would silently break the elapsed-time timer.
const raw = await this.fetch<unknown>(
`/api/chat/sessions/${sessionId}/messages`,
{
method: "POST",
body: JSON.stringify({ content }),
},
);
const parsed = SendChatMessageResponseSchema.safeParse(raw);
if (!parsed.success) {
console.error("[api] ← shape mismatch POST /api/chat/sessions/:id/messages", {
issues: parsed.error.issues,
});
throw new ApiError("Send message response invalid", 0, raw);
}
return parsed.data;
}
async getPendingChatTask(
sessionId: string,
opts?: { signal?: AbortSignal },
): Promise<ChatPendingTask> {
const raw = await this.fetch<unknown>(
`/api/chat/sessions/${sessionId}/pending-task`,
{ signal: opts?.signal },
);
return parseWithFallback(
raw,
ChatPendingTaskSchema,
EMPTY_CHAT_PENDING_TASK,
{ endpoint: "GET /api/chat/sessions/:id/pending-task" },
);
}
async markChatSessionRead(sessionId: string): Promise<void> {
await this.fetch<void>(
`/api/chat/sessions/${sessionId}/read`,
{ method: "POST" },
);
}
async cancelTaskById(taskId: string): Promise<void> {
await this.fetch<void>(`/api/tasks/${taskId}/cancel`, { method: "POST" });
}
// --- File Upload ---
/**
* Multipart-stream a file to `/api/upload-file`. Mirrors the web
* implementation in `packages/core/api/client.ts:uploadFile` but with the
* RN-shaped `FileAsset` instead of a browser `File`. The fetch FormData
* polyfill recognises `{ uri, name, type }` and reads the file off disk.
*
* `opts.issueId` / `opts.commentId` link the attachment record. Pass
* `issueId` when uploading from a comment composer / reply input; leave
* both empty when uploading from a not-yet-created issue (the attachment
* is hooked to the issue once it's created — same flow as web).
*
* Does NOT use `this.fetch` because:
* - FormData must not have a `Content-Type` header preset (the browser /
* RN fetch needs to set the multipart boundary itself).
* - `this.fetch` hard-codes `application/json`.
*
* So we re-implement the auth + slug + logging shell inline.
*/
async uploadFile(
asset: FileAsset,
opts?: { issueId?: string; commentId?: string },
): Promise<Attachment> {
const rid = createRequestId();
const start = Date.now();
const path = "/api/upload-file";
const headers: Record<string, string> = {
// No Content-Type — let fetch set the multipart boundary.
"X-Client-Platform": "mobile",
"X-Client-OS": "ios",
"X-Client-Version": "0.1.0",
"X-Request-ID": rid,
};
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
const slug = getCurrentSlug();
if (slug) headers["X-Workspace-Slug"] = slug;
const formData = new FormData();
// RN's FormData accepts `{ uri, name, type }` as the file value.
// `as never` quiets TS (the global FormData type expects `Blob | string`).
formData.append(
"file",
{ uri: asset.uri, name: asset.name, type: asset.type } as never,
);
if (opts?.issueId) formData.append("issue_id", opts.issueId);
if (opts?.commentId) formData.append("comment_id", opts.commentId);
console.log(`[api] → POST ${path}`, { rid, filename: asset.name });
const res = await fetch(`${API_URL}${path}`, {
method: "POST",
headers,
body: formData,
});
const duration = Date.now() - start;
if (!res.ok) {
if (res.status === 401) this.options.onUnauthorized?.();
let body: unknown;
try {
body = await res.json();
} catch {
body = undefined;
}
const message =
(body && typeof body === "object" && "message" in body
? String((body as { message: unknown }).message)
: null) ?? `Upload failed: ${res.status}`;
console.error(`[api] ← ${res.status} ${path}`, {
rid,
duration: `${duration}ms`,
error: message,
});
throw new ApiError(message, res.status, body);
}
console.log(`[api] ← ${res.status} ${path}`, {
rid,
duration: `${duration}ms`,
});
// Strict validation: parseWithFallback's silent-fallback pattern doesn't
// fit here — an attachment without a `url` would be inserted into the
// user's text as `![](undefined)`. Throw on shape mismatch so the
// caller's Alert path fires instead of letting a broken link land in
// the editor.
const json: unknown = await res.json();
const parsed = AttachmentSchema.safeParse(json);
if (!parsed.success) {
console.error(`[api] ← shape mismatch ${path}`, {
rid,
error: parsed.error.message,
});
throw new ApiError("Upload response invalid", res.status, json);
}
return parsed.data;
}
}
export { MAX_FILE_SIZE };
export const api = new ApiClient();