/** * 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, CreateLabelRequest, CreateProjectRequest, CreateProjectResourceRequest, InboxItem, Issue, IssueLabelsResponse, Label, IssueReaction, ListIssuesParams, ListIssuesResponse, ListLabelsResponse, ListProjectResourcesResponse, ListProjectsResponse, MemberWithUser, PinnedItem, PinnedItemType, Project, ProjectResource, Reaction, ReorderPinsRequest, RuntimeDevice, SearchIssuesResponse, SearchProjectsResponse, SendChatMessageResponse, Squad, NotificationPreferenceResponse, NotificationPreferences, TaskMessagePayload, 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, CommentSchema, 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_COMMENT, 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_PIN_LIST, 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, PinListSchema, PinnedItemSchema, ProjectSchema, RuntimeListSchema, SearchIssuesResponseSchema, SearchProjectsResponseSchema, SendChatMessageResponseSchema, SquadListSchema, TaskMessageListSchema, EMPTY_TASK_MESSAGE_LIST, 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( path: string, init: RequestInit & { signal?: AbortSignal } = {}, ): Promise { const rid = createRequestId(); const start = Date.now(); const method = init.method ?? "GET"; const headers: Record = { "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) ?? {}), }; 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(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( path: string, schema: ZodType, fallback: T, opts?: { signal?: AbortSignal; endpoint?: string }, ): Promise { const raw = await this.fetch(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( path: string, schema: ZodType, fallback: T, init: RequestInit, opts?: { signal?: AbortSignal; endpoint?: string }, ): Promise { // `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(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 { await this.fetch("/auth/send-code", { method: "POST", body: JSON.stringify({ email }), }); } async verifyCode(email: string, code: string): Promise { return this.fetch("/auth/verify-code", { method: "POST", body: JSON.stringify({ email, code }), }); } async getMe(opts?: { signal?: AbortSignal }): Promise { 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 { return this.fetchValidatedWith( "/api/me", UserSchema, EMPTY_USER, { method: "PATCH", body: JSON.stringify(data) }, { endpoint: "updateMe" }, ); } // --- Notification preferences --- async getNotificationPreferences( opts?: { signal?: AbortSignal }, ): Promise { return this.fetchValidated( "/api/notification-preferences", NotificationPreferenceResponseSchema, EMPTY_NOTIFICATION_PREFERENCES, { ...opts, endpoint: "getNotificationPreferences" }, ); } async updateNotificationPreferences( preferences: NotificationPreferences, ): Promise { 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 { const raw = await this.fetch("/api/workspaces", { signal: opts?.signal, }); return parseWithFallback(raw, WorkspaceListSchema, EMPTY_WORKSPACE_LIST, { endpoint: "listWorkspaces", }); } // --- Inbox --- async listInbox(opts?: { signal?: AbortSignal }): Promise { const raw = await this.fetch("/api/inbox", { signal: opts?.signal, }); return parseWithFallback(raw, InboxListSchema, EMPTY_INBOX_LIST, { endpoint: "listInbox", }); } async markInboxRead(id: string): Promise { return this.fetch(`/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 { return this.fetch(`/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 { const raw = await this.fetch( `/api/workspaces/${workspaceId}/members`, { signal: opts?.signal }, ); return parseWithFallback(raw, MemberListSchema, EMPTY_MEMBER_LIST, { endpoint: "listMembers", }); } async listAgents(opts?: { signal?: AbortSignal }): Promise { const raw = await this.fetch("/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 { const raw = await this.fetch("/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 { const raw = await this.fetch("/api/agent-task-snapshot", { signal: opts?.signal, }); return parseWithFallback(raw, AgentTaskListSchema, EMPTY_AGENT_TASK_LIST, { endpoint: "listAgentTaskSnapshot", }); } async listSquads(opts?: { signal?: AbortSignal }): Promise { const raw = await this.fetch("/api/squads", { signal: opts?.signal, }); return parseWithFallback(raw, SquadListSchema, EMPTY_SQUAD_LIST, { endpoint: "listSquads", }); } // --- Issues --- async listIssues( params: ListIssuesParams = {}, opts?: { signal?: AbortSignal }, ): Promise { 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( `/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 { 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( `/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 { 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 { return this.fetch("/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 { 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/` // 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 { 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 { 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 { 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, opts?: { parentId?: string; type?: string; attachmentIds?: string[] }, ): Promise { // 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. // `type` defaults to "comment" matching web client.ts:686. return this.fetchValidatedWith( `/api/issues/${issueId}/comments`, CommentSchema, EMPTY_COMMENT, { method: "POST", body: JSON.stringify({ content, type: opts?.type ?? "comment", ...(opts?.parentId ? { parent_id: opts.parentId } : {}), ...(opts?.attachmentIds ? { attachment_ids: opts.attachmentIds } : {}), }), }, { endpoint: "createComment" }, ); } // PUT /api/comments/:id — content edit (+ optional attachment swap). async updateComment( commentId: string, content: string, attachmentIds?: string[], ): Promise { return this.fetchValidatedWith( `/api/comments/${commentId}`, CommentSchema, EMPTY_COMMENT, { method: "PUT", body: JSON.stringify({ content, ...(attachmentIds ? { attachment_ids: attachmentIds } : {}), }), }, { endpoint: "updateComment" }, ); } // DELETE /api/comments/:id — 204 No Content on success; this.fetch // already short-circuits 204 → undefined. async deleteComment(commentId: string): Promise { await this.fetch(`/api/comments/${commentId}`, { method: "DELETE" }); } // POST /api/comments/:id/resolve — marks the thread root resolved; only // meaningful for root comments. Backend mirrors web semantics. async resolveComment(commentId: string): Promise { return this.fetchValidatedWith( `/api/comments/${commentId}/resolve`, CommentSchema, EMPTY_COMMENT, { method: "POST" }, { endpoint: "resolveComment" }, ); } // DELETE /api/comments/:id/resolve — un-resolves the thread. async unresolveComment(commentId: string): Promise { return this.fetchValidatedWith( `/api/comments/${commentId}/resolve`, CommentSchema, EMPTY_COMMENT, { method: "DELETE" }, { endpoint: "unresolveComment" }, ); } // --- 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 { return this.fetch(`/api/comments/${commentId}/reactions`, { method: "POST", body: JSON.stringify({ emoji }), }); } async removeReaction(commentId: string, emoji: string): Promise { await this.fetch(`/api/comments/${commentId}/reactions`, { method: "DELETE", body: JSON.stringify({ emoji }), }); } async addIssueReaction( issueId: string, emoji: string, ): Promise { return this.fetch(`/api/issues/${issueId}/reactions`, { method: "POST", body: JSON.stringify({ emoji }), }); } async removeIssueReaction(issueId: string, emoji: string): Promise { await this.fetch(`/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 { return this.fetch(`/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 { await this.fetch(`/api/issues/${id}`, { method: "DELETE" }); } // --- Labels --- async listLabels(opts?: { signal?: AbortSignal; }): Promise { const raw = await this.fetch("/api/labels", { signal: opts?.signal, }); return parseWithFallback( raw, ListLabelsResponseSchema, EMPTY_LIST_LABELS_RESPONSE, { endpoint: "GET /api/labels" }, ); } // Create a new label and return it. Response is consumed by the // create-and-attach flow in label picker, so raw `this.fetch