mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-13 05:16:29 +02:00
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>
1110 lines
36 KiB
TypeScript
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 ``. 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();
|