mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 10:59:06 +02:00
* feat(chat): add agent-generated quick actions
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): preserve mid-response quick-action fences
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): drop quick actions on empty reply to keep no_response fallback
An actions-only completion — a quick-actions footer with no visible text —
wrote an empty-content assistant message (message_kind=message). Older
Desktop/mobile clients ignore the quick_actions field and render that as an
empty bubble, breaking the MUL-4351 contract that an empty turn always gives
old clients a visible no_response fallback.
Drop the quick actions when the visible body is empty so an actions-only turn
falls through to the visible no_response outcome, and revert the completion
switch to gate the message row on visible text only. Update the completion
test to pin the corrected behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): generate quick actions via daemon suggestion pass
Replace the in-band runtime-brief instruction with a dedicated post-completion
provider turn: after a direct chat reply finishes, the daemon resumes the same
session with a JSON-only suggest prompt and forwards the raw output on the
complete callback. The server parses it leniently and reuses the existing
sanitize/redact/store/broadcast pipeline; the stripped in-band footer stays as
a fallback for older daemons and pre-upgrade sessions. The footer strip now
covers every chat completion, fixing the intro-turn protocol leak. Adds a
Settings → Chat toggle (client-persisted, default on) that hides the chips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(chat): deliver quick actions async with skeleton placeholders
Decouple suggestion generation from the turn: the daemon reports completion
immediately (chat:done carries quick_actions_pending as a per-turn capability
signal) and runs the suggestion pass in the background, delivering results
through a new supplement endpoint + chat:quick_actions broadcast. A new turn
on the same session cancels the stale pass. Clients render pill skeletons
under the finished reply until the supplement resolves them (entrance
animation on arrival, 30s safety timeout); older daemons never raise the flag
so no skeleton dangles. Suggest usage re-reports merged totals because
task_usage upserts replace per (task, provider, model). Prompt now asks for
exactly 3 actions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(chat): make the quick-actions toggle stop generation, not hide pills
The Settings → Chat toggle previously only hid rendered pills while the
daemon kept burning a suggestion call every turn. It now travels with each
send (quick_actions_enabled, absent = enabled for older clients), is stamped
on the chat task (migration 213), forwarded on the claim, and gates the
daemon's suggestion pass at the source — no call, no pending flag, no
skeleton. Existing suggestions stay visible; settings copy now says
'generate' instead of 'show'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(migrations): renumber quick-action migrations onto current main
Merging current origin/main brought the vcs migrations to their canonical
216-221 prefixes, which collided with the quick-action migrations that were
sitting at 219/220 (backend CI red in
TestMigrationNumericPrefixesStayUniqueAfterLegacySet). Renumber them to the
next unused prefixes:
- 219_chat_message_quick_actions -> 222_chat_message_quick_actions
- 220_agent_task_quick_actions_disabled -> 223_agent_task_quick_actions_disabled
Contents are unchanged; sqlc regeneration produces no drift since the added
columns are independent of the vcs tables.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(mobile): render async chat quick actions via chat:quick_actions
The daemon generates quick actions in a background pass after the turn
finishes, delivering them on a separate chat:quick_actions event. Mobile
only handled chat:done (which invalidates + refetches an actions-less
message list) and keeps the messages query at staleTime: Infinity, so an
active mobile session never rendered async-generated quick actions until a
manual pull-to-refresh or refocus.
Add applyChatQuickActionsToCache — mirroring web's patcher — which patches
the supplement onto the targeted assistant message in the flat messages
cache, and subscribe to chat:quick_actions in use-chat-session-realtime.
Patch-only (no invalidate), matching web and mobile's cellular
patch-over-invalidate rule; an empty supplement is a terminal no-op. Covered
by chat-ws-updaters.test.ts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): cancel in-flight messages refetch before quick-actions patch
The chat:done invalidate can leave a messages refetch in flight that read the
assistant row before the daemon persisted the quick actions. If that refetch
resolves after the chat:quick_actions setQueryData patch, it overwrites the
freshly-patched actions with an actions-less row. Both message caches are
staleTime: Infinity, so the overwrite never self-heals and the actions vanish
permanently (MUL-5149, Howard review).
applyChatQuickActionsToCache now awaits cancelQueries for the affected caches
(web: flat messages + messagesPage, mobile: flat messages) before patching, so
a stale in-flight refetch is cancelled and cannot land after the patch. Cancel
must precede setQueryData because cancelQueries reverts to the pre-fetch state.
WS handlers call it via `void` (fire-and-forget).
Adds an active-query race regression test on both web and mobile that holds a
refetch open across the supplement and asserts the patched actions survive;
verified to fail without the cancel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): quick-actions refresh/regenerate + review hardening (MUL-5149)
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): address quick-actions re-review (MUL-5149)
- Ack alignment: refresh request carries the target message_id; server
atomically confirms it is still the session's latest turn (409 stale
otherwise), so the client marker always matches the resolving
chat:quick_actions — no response reconciliation. Adds a regression test.
- Converge the pending marker on every terminal path: HandleFailedTasks
(sweeper/orphan) now resolves it, and the daemon reports a failed supplement
so FailTask resolves it instead of leaving a completed-but-unresolved task.
- Timeout fallback now clears the real query state (useQuickActionsPendingTimeout)
instead of a component-local flag that only masked the UI; drop the skeleton's
and pill row's local timers.
- frontend-test type-scale: text-xs -> text-caption. Strip EOF blank line.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): close quick-actions refresh races and failure feedback (MUL-5149)
Third-round review of the refresh button surfaced three issues; all three
are addressed here.
§1/§2 Session-busy race + concurrent-refresh double-spend: a newer reply
that is queued/running but whose assistant row hasn't landed leaves the old
turn as latest-persisted, so the stale check passes and the regen resumes
the newer provider state — attaching suggestions to the wrong turn. And two
concurrent refreshes each enqueue a quota-spending pass. Add
HasActiveChatTaskForSession and refuse a refresh (ErrChatQuickActionsBusy →
409) whenever the session has any task in flight, checked under the same
session lock as the enqueue so no sibling insert slips past.
§3a Timeout re-arm on surface switch: the pending marker now carries an
absolute expires_at deadline instead of a per-mount timer, so switching
between the floating window and the chat tab resumes the same deadline
rather than restarting a fresh 30s window each remount.
§3b Generation failure masked as success: runChatSuggestPass now returns ok
so an explicit refresh distinguishes a failed pass (didn't start / didn't
complete / timed out) from a completed-but-empty one. On failure the regen
task reports failure, resolveFailedRegenerateQuickActions broadcasts a
FAILED chat:quick_actions, and the client resolves the spinner AND toasts
"couldn't refresh" instead of silently stopping on unchanged pills.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): count deferred tasks in refresh busy check; solid refresh icon tone (MUL-5149)
Two re-review blockers on 2dc9404d.
§1 (deferred window): HasActiveChatTaskForSession only treated
queued/dispatched/running/waiting_local_directory as in-flight, so a chat
auto-retry armed with a backoff fire_at — inserted 'deferred' by
CreateRetryTask, as provider_network's ~5s final attempt is — slipped past
the busy check. In that window the failed turn has no assistant row yet, so
the old turn is still latest-persisted and refreshable; the regen would then
resume a session the retry is about to advance and pin the new turn's
suggestions onto the old one. Add 'deferred' so the set matches the
canonical in-flight status list the rest of the queries already use
(agent.sql has-active-task checks). New regression test covers a deferred
active turn.
CI (text-contrast gate): the refresh icon button used
text-muted-foreground/70 (transparency standing in for a text tone), which
the frontend-test contrast gate rejects. Switch to the solid
text-faint-foreground token — the tone the gate recommends for icons/glyphs,
already used repo-wide and clearing WCAG 1.4.11.
Co-authored-by: multica-agent <github@multica.ai>
* test(chat): assert quick-actions pending marker carries expires_at (MUL-5149)
The chat:done supplement-flow test still expected the 2-field marker from
before the absolute-deadline change; applyChatDoneToCache now stamps
expires_at, so the deep-equal failed on frontend-test. Assert the deadline is
present (expect.any(Number)) rather than a wall-clock-dependent value — its
timing semantics are covered by the pending-timeout hook.
Co-authored-by: multica-agent <github@multica.ai>
* fix(db): renumber regenerate-quick-actions migration 237 -> 240 (MUL-5149)
main merged Issue Quick Actions (MUL-5465) taking migrations 237/238/239
(quick_action, quick_action_workspace_index, comment_quick_action). This
branch independently took 237 for agent_task_queue.regenerate_quick_actions_for.
The two 237s do not textually conflict (different filenames) so the PR reads
mergeable, but the merged tree would carry two migration 237s. Renumber this
one to 240 so it applies after main's chain. The migration is a standalone
`ALTER TABLE agent_task_queue ADD COLUMN IF NOT EXISTS` — order-independent,
touches a column none of main's migrations reference.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: NevilleQingNY <nevilleqing@gmail.com>
712 lines
27 KiB
TypeScript
712 lines
27 KiB
TypeScript
/**
|
|
* Mobile-local zod schemas + fallbacks for endpoints whose responses aren't
|
|
* yet schematised in @multica/core/api/schemas. Lenient by design — see the
|
|
* leniency rationale at the top of the core file (string enums tolerated,
|
|
* loose() so unknown server fields pass through, defaults so a missing
|
|
* array doesn't take the page down).
|
|
*
|
|
* If web/desktop later need these same schemas, promote them to core; until
|
|
* then they live here so mobile satisfies its "Parse, don't cast" rule
|
|
* (root CLAUDE.md "API Response Compatibility") for these endpoints.
|
|
*/
|
|
import { z } from "zod";
|
|
import type {
|
|
Agent,
|
|
AgentInvocationTarget,
|
|
AgentTask,
|
|
Attachment,
|
|
ChatMessage,
|
|
ChatPendingTask,
|
|
ChatSession,
|
|
Comment,
|
|
InboxItem,
|
|
IssueLabelsResponse,
|
|
Label,
|
|
ListLabelsResponse,
|
|
ListProjectResourcesResponse,
|
|
ListProjectsResponse,
|
|
MemberWithUser,
|
|
PinnedItem,
|
|
Project,
|
|
ProjectResource,
|
|
RuntimeDevice,
|
|
SearchIssuesResponse,
|
|
SearchProjectsResponse,
|
|
SendChatMessageResponse,
|
|
Squad,
|
|
TaskMessagePayload,
|
|
User,
|
|
Workspace,
|
|
} from "@multica/core/types";
|
|
import { IssueSchema } from "@multica/core/api/schemas";
|
|
|
|
/** Upload response. Only fields mobile actually consumes — `url` to put
|
|
* into the markdown link, `filename` for the `[📎 name](url)` form, `id`
|
|
* for future linking. `.loose()` so the server can add fields without
|
|
* breaking mobile. Web's AttachmentSchema (packages/core/api/schemas.ts:41)
|
|
* is even looser (only `id`); mobile validates more because the upload
|
|
* flow inserts `url` directly into editable text and an empty `url` would
|
|
* produce a broken link the user only notices after submit. */
|
|
export const AttachmentSchema: z.ZodType<Attachment> = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string().default(""),
|
|
issue_id: z.string().nullable().default(null),
|
|
comment_id: z.string().nullable().default(null),
|
|
chat_session_id: z.string().nullable().default(null),
|
|
chat_message_id: z.string().nullable().default(null),
|
|
uploader_type: z.string().default(""),
|
|
uploader_id: z.string().default(""),
|
|
filename: z.string(),
|
|
url: z.string(),
|
|
download_url: z.string().default(""),
|
|
markdown_url: z.string().default(""),
|
|
content_type: z.string().default(""),
|
|
size_bytes: z.number().default(0),
|
|
created_at: z.string().default(""),
|
|
}).loose();
|
|
|
|
/** GET /api/issues/:id/attachments — array of attachments for the issue.
|
|
* Empty array fallback so a 5xx or shape mismatch doesn't crash markdown
|
|
* rendering — image URIs simply fail to resolve and fall back to fetch. */
|
|
export const AttachmentListSchema = z.array(AttachmentSchema).default([]);
|
|
export const EMPTY_ATTACHMENT_LIST: Attachment[] = [];
|
|
|
|
/** Comment write endpoints all return a full Comment. Used by createComment /
|
|
* updateComment / resolveComment / unresolveComment via fetchValidatedWith.
|
|
* Empty fallback yields `id: ""` so downstream code (the mutations'
|
|
* onSuccess writers) can detect drift and fall back to invalidate. */
|
|
export const CommentSchema = z.object({
|
|
id: z.string(),
|
|
issue_id: z.string().default(""),
|
|
author_type: z.string().default("member"),
|
|
author_id: z.string().default(""),
|
|
content: z.string().default(""),
|
|
type: z.string().default("comment"),
|
|
parent_id: z.string().nullable().default(null),
|
|
reactions: z.array(z.unknown()).default([]),
|
|
attachments: z.array(z.unknown()).default([]),
|
|
created_at: z.string().default(""),
|
|
updated_at: z.string().default(""),
|
|
resolved_at: z.string().nullable().default(null),
|
|
resolved_by_type: z.string().nullable().default(null),
|
|
resolved_by_id: z.string().nullable().default(null),
|
|
source_task_id: z.string().nullable().optional(),
|
|
}).loose() as unknown as z.ZodType<Comment>;
|
|
|
|
export const EMPTY_COMMENT: Comment = {
|
|
id: "",
|
|
issue_id: "",
|
|
author_type: "member",
|
|
author_id: "",
|
|
content: "",
|
|
type: "comment",
|
|
parent_id: null,
|
|
reactions: [],
|
|
attachments: [],
|
|
created_at: "",
|
|
updated_at: "",
|
|
resolved_at: null,
|
|
resolved_by_type: null,
|
|
resolved_by_id: null,
|
|
};
|
|
|
|
/** GET/PUT /api/notification-preferences. Preferences are partial — absent
|
|
* keys mean "default (= all)", an explicit "muted" turns the group off.
|
|
* Loose() so future group additions on the backend don't break parsing.
|
|
* Value type is z.string() (not z.enum) so a future server-side value like
|
|
* "snoozed" downgrades gracefully (read sites treat unknown as enabled)
|
|
* instead of failing schema parse and dropping the entire preferences map.
|
|
* Per CLAUDE.md "Enum drift downgrades, not crashes". */
|
|
export const NotificationPreferenceResponseSchema = z.object({
|
|
workspace_id: z.string().default(""),
|
|
preferences: z.record(z.string(), z.string()).default({}),
|
|
}).loose();
|
|
export const EMPTY_NOTIFICATION_PREFERENCES = {
|
|
workspace_id: "",
|
|
preferences: {},
|
|
} as const;
|
|
|
|
const LabelSchema = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string(),
|
|
name: z.string(),
|
|
color: z.string(),
|
|
created_at: z.string(),
|
|
updated_at: z.string(),
|
|
}).loose();
|
|
|
|
export const ListLabelsResponseSchema = z.object({
|
|
labels: z.array(LabelSchema).default([]),
|
|
total: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const EMPTY_LIST_LABELS_RESPONSE: ListLabelsResponse = {
|
|
labels: [],
|
|
total: 0,
|
|
};
|
|
|
|
export const IssueLabelsResponseSchema = z.object({
|
|
labels: z.array(LabelSchema).default([]),
|
|
}).loose();
|
|
|
|
export const EMPTY_ISSUE_LABELS_RESPONSE: IssueLabelsResponse = {
|
|
labels: [],
|
|
};
|
|
|
|
export const ProjectSchema = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string(),
|
|
title: z.string(),
|
|
description: z.string().nullable(),
|
|
icon: z.string().nullable(),
|
|
status: z.string(),
|
|
priority: z.string(),
|
|
lead_type: z.string().nullable(),
|
|
lead_id: z.string().nullable(),
|
|
// .default(null) so a project from an older backend that omits these keys
|
|
// parses to null instead of degrading the batch to the empty fallback.
|
|
start_date: z.string().nullable().default(null),
|
|
due_date: z.string().nullable().default(null),
|
|
created_at: z.string(),
|
|
updated_at: z.string(),
|
|
issue_count: z.number().default(0),
|
|
done_count: z.number().default(0),
|
|
resource_count: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const ListProjectsResponseSchema = z.object({
|
|
projects: z.array(ProjectSchema).default([]),
|
|
total: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const EMPTY_LIST_PROJECTS_RESPONSE: ListProjectsResponse = {
|
|
projects: [],
|
|
total: 0,
|
|
};
|
|
|
|
// Fallback for `GET /api/projects/{id}` when the response shape drifts.
|
|
// `id` defaults to empty — caller can detect "not found / drift" by checking
|
|
// `data.id === ""` and rendering an error state instead of pretending the
|
|
// data is valid. Status / priority cast to the enum literals so TS callers
|
|
// downstream still flow correctly; runtime values came from the schema
|
|
// (`z.string()`), which would have already passed.
|
|
export const EMPTY_PROJECT: Project = {
|
|
id: "",
|
|
workspace_id: "",
|
|
title: "",
|
|
description: null,
|
|
icon: null,
|
|
status: "planned",
|
|
priority: "none",
|
|
lead_type: null,
|
|
lead_id: null,
|
|
start_date: null,
|
|
due_date: null,
|
|
created_at: "",
|
|
updated_at: "",
|
|
issue_count: 0,
|
|
done_count: 0,
|
|
resource_count: 0,
|
|
};
|
|
|
|
// Project resources are typed pointers to external resources (today: GitHub
|
|
// repos). resource_ref shape varies per resource_type; lenient on both
|
|
// `resource_type` (so a future type doesn't crash the list) and
|
|
// `resource_ref` (passes through unchanged for the renderer to dispatch on).
|
|
const ProjectResourceSchema = z.object({
|
|
id: z.string(),
|
|
project_id: z.string(),
|
|
workspace_id: z.string(),
|
|
resource_type: z.string(),
|
|
resource_ref: z.unknown(),
|
|
label: z.string().nullable(),
|
|
position: z.number().default(0),
|
|
created_at: z.string(),
|
|
created_by: z.string().nullable(),
|
|
}).loose();
|
|
|
|
export const ListProjectResourcesResponseSchema = z.object({
|
|
resources: z.array(ProjectResourceSchema).default([]),
|
|
total: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const EMPTY_LIST_PROJECT_RESOURCES_RESPONSE: ListProjectResourcesResponse = {
|
|
resources: [],
|
|
total: 0,
|
|
};
|
|
|
|
// =====================================================
|
|
// Chat (sessions / messages / pending task)
|
|
// =====================================================
|
|
// Lenient on every field that's purely informational (status enum, timestamps,
|
|
// agent/creator ids). `.loose()` so server-added fields pass through. The two
|
|
// fields mobile keys behaviour on — `id` and `chat_session_id` — are required.
|
|
|
|
export const ChatSessionSchema: z.ZodType<ChatSession> = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string().default(""),
|
|
agent_id: z.string().default(""),
|
|
creator_id: z.string().default(""),
|
|
title: z.string().default(""),
|
|
// Enum drift defense (root CLAUDE.md "Enum drift downgrades, not crashes"):
|
|
// unknown server values fall back to "active" so the row still renders.
|
|
status: z.enum(["active", "archived"]).catch("active"),
|
|
has_unread: z.boolean().default(false),
|
|
// Unread assistant messages after the read cursor. Optional (not defaulted)
|
|
// so the badge math can tell "older server didn't send it" from a real 0 —
|
|
// the tab badge sums `unread_count ?? 0`, same rule as web's sidebar.
|
|
unread_count: z.number().optional(),
|
|
created_at: z.string().default(""),
|
|
updated_at: z.string().default(""),
|
|
}).loose();
|
|
|
|
export const ChatSessionListSchema = z.array(ChatSessionSchema).default([]);
|
|
|
|
export const EMPTY_CHAT_SESSION_LIST: ChatSession[] = [];
|
|
|
|
// `attachments` carried for parity rendering only — v1 doesn't author them on
|
|
// mobile. AttachmentSchema is reused as-is.
|
|
export const ChatMessageSchema: z.ZodType<ChatMessage> = z.object({
|
|
id: z.string(),
|
|
chat_session_id: z.string(),
|
|
// If the server ever introduces a third role, fall back to "assistant" so
|
|
// the message renders (as a left-aligned bubble) instead of crashing the
|
|
// list. Matches Enum drift defense.
|
|
role: z.enum(["user", "assistant"]).catch("assistant"),
|
|
content: z.string().default(""),
|
|
task_id: z.string().nullable().default(null),
|
|
created_at: z.string().default(""),
|
|
attachments: z.array(AttachmentSchema).optional(),
|
|
failure_reason: z.string().nullable().optional(),
|
|
elapsed_ms: z.number().nullable().optional(),
|
|
message_kind: z.enum(["message", "no_response"]).catch("message").optional(),
|
|
// One malformed optional suggestion must not erase an otherwise valid
|
|
// conversation. The server validates these too; this is mixed-version and
|
|
// corrupted-cache defense at the mobile boundary.
|
|
quick_actions: z.array(z.object({
|
|
label: z.string(),
|
|
prompt: z.string(),
|
|
primary: z.boolean().optional(),
|
|
}).loose()).catch([]).optional().default([]),
|
|
}).loose();
|
|
|
|
export const ChatMessageListSchema = z.array(ChatMessageSchema).default([]);
|
|
|
|
export const EMPTY_CHAT_MESSAGE_LIST: ChatMessage[] = [];
|
|
|
|
// All fields optional — server returns an empty object when no in-flight task.
|
|
export const ChatPendingTaskSchema: z.ZodType<ChatPendingTask> = z.object({
|
|
task_id: z.string().optional(),
|
|
status: z.string().optional(),
|
|
created_at: z.string().optional(),
|
|
}).loose();
|
|
|
|
export const EMPTY_CHAT_PENDING_TASK: ChatPendingTask = {};
|
|
|
|
export const SendChatMessageResponseSchema: z.ZodType<SendChatMessageResponse> = z.object({
|
|
message_id: z.string(),
|
|
task_id: z.string(),
|
|
created_at: z.string().default(""),
|
|
}).loose();
|
|
|
|
// Live timeline emitted by the agent runtime while a task is running. Each
|
|
// row is one execution step (thinking / tool_use / tool_result / text /
|
|
// error). Mirrors web's TaskMessagePayload type and the WS `task:message`
|
|
// payload so the mobile cache shape stays interchangeable with web's.
|
|
export const TaskMessagePayloadSchema: z.ZodType<TaskMessagePayload> = z.object({
|
|
task_id: z.string(),
|
|
issue_id: z.string().default(""),
|
|
chat_session_id: z.string().optional(),
|
|
seq: z.number().default(0),
|
|
// Enum drift defense: unknown server-side types fall back to "text" so
|
|
// the row still renders (as a plain markdown chunk) instead of crashing
|
|
// the timeline. Matches root CLAUDE.md "Enum drift downgrades, not crashes".
|
|
type: z
|
|
.enum(["text", "thinking", "tool_use", "tool_result", "error"])
|
|
.catch("text"),
|
|
tool: z.string().optional(),
|
|
content: z.string().optional(),
|
|
input: z.record(z.string(), z.unknown()).optional(),
|
|
output: z.string().optional(),
|
|
created_at: z.string().optional(),
|
|
}).loose();
|
|
|
|
export const TaskMessageListSchema = z.array(TaskMessagePayloadSchema).default([]);
|
|
|
|
export const EMPTY_TASK_MESSAGE_LIST: TaskMessagePayload[] = [];
|
|
|
|
// =====================================================
|
|
// Search (issues + projects)
|
|
// =====================================================
|
|
// Mirrors SearchIssueResult / SearchProjectResult in packages/core/types/api.ts.
|
|
// Web does not currently route search responses through parseWithFallback, so
|
|
// the schemas live mobile-side. Promote to core when web adopts the same
|
|
// defense.
|
|
//
|
|
// match_source is the server's hint of which field matched. Enum-drift defense
|
|
// (root CLAUDE.md "Enum drift downgrades, not crashes"): unknown values fall
|
|
// back to "title" so the row still renders without a snippet line.
|
|
|
|
const SearchIssueResultSchema = IssueSchema.safeExtend({
|
|
match_source: z.enum(["title", "description", "comment"]).catch("title"),
|
|
matched_snippet: z.string().optional(),
|
|
});
|
|
|
|
export const SearchIssuesResponseSchema = z.object({
|
|
issues: z.array(SearchIssueResultSchema).default([]),
|
|
total: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const EMPTY_SEARCH_ISSUES_RESPONSE: SearchIssuesResponse = {
|
|
issues: [],
|
|
total: 0,
|
|
};
|
|
|
|
const SearchProjectResultSchema = ProjectSchema.safeExtend({
|
|
match_source: z.enum(["title", "description"]).catch("title"),
|
|
matched_snippet: z.string().optional(),
|
|
});
|
|
|
|
export const SearchProjectsResponseSchema = z.object({
|
|
projects: z.array(SearchProjectResultSchema).default([]),
|
|
total: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const EMPTY_SEARCH_PROJECTS_RESPONSE: SearchProjectsResponse = {
|
|
projects: [],
|
|
total: 0,
|
|
};
|
|
|
|
// =====================================================
|
|
// Agent tasks (per-issue runs, active + history)
|
|
// =====================================================
|
|
// Mirrors AgentTask in packages/core/types/agent.ts. Backend handlers:
|
|
// GET /api/issues/{id}/active-task → { tasks: AgentTask[] } (may be empty)
|
|
// GET /api/issues/{id}/task-runs → AgentTask[]
|
|
// Lenient on every field — status / kind / failure_reason all use `.catch()`
|
|
// so a future server-side enum value renders a generic fallback rather than
|
|
// crashing the row (root CLAUDE.md "Enum drift downgrades, not crashes").
|
|
|
|
export const AgentTaskSchema: z.ZodType<AgentTask> = z.object({
|
|
id: z.string(),
|
|
agent_id: z.string().default(""),
|
|
runtime_id: z.string().default(""),
|
|
issue_id: z.string().default(""),
|
|
status: z
|
|
.enum(["queued", "dispatched", "running", "completed", "failed", "cancelled"])
|
|
.catch("queued"),
|
|
priority: z.number().default(0),
|
|
dispatched_at: z.string().nullable().default(null),
|
|
started_at: z.string().nullable().default(null),
|
|
completed_at: z.string().nullable().default(null),
|
|
result: z.unknown().default(null),
|
|
error: z.string().nullable().default(null),
|
|
// Backend uses empty string ("") as the "not failed" sentinel (Go
|
|
// `omitempty` on a custom string-typed enum). Normalize that to `undefined`
|
|
// so downstream truthy checks (`if (task.failure_reason)`) don't have to
|
|
// special-case both null/undefined AND "".
|
|
failure_reason: z
|
|
.enum(["agent_error", "timeout", "runtime_offline", "runtime_recovery", "manual", ""])
|
|
.optional()
|
|
.catch("")
|
|
.transform((v) => (v === "" ? undefined : v)),
|
|
created_at: z.string().default(""),
|
|
chat_session_id: z.string().optional(),
|
|
autopilot_run_id: z.string().optional(),
|
|
parent_task_id: z.string().optional(),
|
|
attempt: z.number().optional(),
|
|
trigger_comment_id: z.string().optional(),
|
|
trigger_summary: z.string().optional(),
|
|
kind: z.enum(["comment", "autopilot", "chat", "quick_create", "direct"]).optional().catch("direct"),
|
|
work_dir: z.string().optional(),
|
|
}).loose();
|
|
|
|
export const AgentTaskListSchema = z.array(AgentTaskSchema).default([]);
|
|
|
|
export const ActiveTasksResponseSchema = z.object({
|
|
tasks: z.array(AgentTaskSchema).default([]),
|
|
}).loose();
|
|
|
|
export interface ActiveTasksResponse {
|
|
tasks: AgentTask[];
|
|
}
|
|
|
|
export const EMPTY_AGENT_TASK_LIST: AgentTask[] = [];
|
|
export const EMPTY_ACTIVE_TASKS_RESPONSE: ActiveTasksResponse = { tasks: [] };
|
|
|
|
// =====================================================
|
|
// User / Workspace / Inbox / Member / Agent
|
|
// =====================================================
|
|
// Mobile reads these on every cold start (auth → workspaces → inbox → members
|
|
// → agents form the boot sequence). A schema drift in any of them used to
|
|
// cascade — getMe failure flushed the user, listWorkspaces failure landed the
|
|
// app on the workspace picker with no entries. With parseWithFallback every
|
|
// drift downgrades to "stale defaults render", and the user can keep working.
|
|
//
|
|
// All five are `.loose()` so additive backend fields (`onboarded_at` style
|
|
// flags) pass through without breaking parsing. Required identity fields
|
|
// (id, slug, etc.) stay required — a response that genuinely lacks them is
|
|
// unusable and parseWithFallback should fall back to the empty sentinel.
|
|
|
|
export const UserSchema: z.ZodType<User> = z.object({
|
|
id: z.string(),
|
|
name: z.string().default(""),
|
|
email: z.string().default(""),
|
|
avatar_url: z.string().nullable().default(null),
|
|
onboarded_at: z.string().nullable().default(null),
|
|
onboarding_questionnaire: z.record(z.string(), z.unknown()).default({}),
|
|
starter_content_state: z.string().nullable().default(null),
|
|
language: z.string().nullable().default(null),
|
|
profile_description: z.string().default(""),
|
|
timezone: z.string().nullable().default(null),
|
|
created_at: z.string().default(""),
|
|
updated_at: z.string().default(""),
|
|
}).loose();
|
|
|
|
// `id: ""` is the sentinel for "drifted / unauthenticated"; downstream code
|
|
// that switches on `user.id` will treat empty-string as a logged-out state
|
|
// (the auth hook also clears the cache on 401, so this is rarely seen).
|
|
export const EMPTY_USER: User = {
|
|
id: "",
|
|
name: "",
|
|
email: "",
|
|
avatar_url: null,
|
|
onboarded_at: null,
|
|
onboarding_questionnaire: {},
|
|
starter_content_state: null,
|
|
language: null,
|
|
profile_description: "",
|
|
timezone: null,
|
|
created_at: "",
|
|
updated_at: "",
|
|
};
|
|
|
|
export const WorkspaceSchema: z.ZodType<Workspace> = z.object({
|
|
id: z.string(),
|
|
name: z.string().default(""),
|
|
slug: z.string().default(""),
|
|
description: z.string().nullable().default(null),
|
|
context: z.string().nullable().default(null),
|
|
settings: z.record(z.string(), z.unknown()).default({}),
|
|
repos: z.array(z.object({ url: z.string() }).loose()).default([]),
|
|
issue_prefix: z.string().default(""),
|
|
avatar_url: z.string().nullable().default(null),
|
|
created_at: z.string().default(""),
|
|
updated_at: z.string().default(""),
|
|
}).loose();
|
|
|
|
export const WorkspaceListSchema = z.array(WorkspaceSchema).default([]);
|
|
export const EMPTY_WORKSPACE_LIST: Workspace[] = [];
|
|
|
|
/** Pin metadata only — display fields (title / status / icon) are NOT here,
|
|
* consumers derive them from `issueDetailOptions` / `projectDetailOptions`.
|
|
* Matches the design in packages/core/types/pin.ts. */
|
|
export const PinnedItemSchema: z.ZodType<PinnedItem> = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string().default(""),
|
|
user_id: z.string().default(""),
|
|
item_type: z.enum(["issue", "project"]).catch("issue"),
|
|
item_id: z.string(),
|
|
position: z.number().default(0),
|
|
created_at: z.string().default(""),
|
|
}).loose();
|
|
|
|
export const PinListSchema = z.array(PinnedItemSchema).default([]);
|
|
export const EMPTY_PIN_LIST: PinnedItem[] = [];
|
|
|
|
const InboxItemSchema: z.ZodType<InboxItem> = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string().default(""),
|
|
// Recipient is always a real actor in the dataset, but defend against
|
|
// either field going missing — mobile's actor lookup tolerates null.
|
|
recipient_type: z.enum(["member", "agent"]).catch("member"),
|
|
recipient_id: z.string().default(""),
|
|
// `actor_type` includes "system" for platform-triggered notifications
|
|
// (packages/core/types/inbox.ts:28). ActorAvatar handles all three plus
|
|
// null. Enum drift falls back to null so the row still renders without an
|
|
// avatar instead of crashing the list.
|
|
actor_type: z
|
|
.enum(["member", "agent", "system"])
|
|
.nullable()
|
|
.catch(null),
|
|
actor_id: z.string().nullable().default(null),
|
|
// `type` discriminates the rendered detail-label. Unknown values pass
|
|
// through as raw strings — `InboxDetailLabel` has a default branch that
|
|
// shows the raw type as fallback (components/inbox/detail-label.tsx).
|
|
type: z.string() as unknown as z.ZodType<InboxItem["type"]>,
|
|
severity: z
|
|
.enum(["action_required", "attention", "info"])
|
|
.catch("info"),
|
|
issue_id: z.string().nullable().default(null),
|
|
title: z.string().default(""),
|
|
body: z.string().nullable().default(null),
|
|
issue_status: z.string().nullable().default(null) as unknown as z.ZodType<
|
|
InboxItem["issue_status"]
|
|
>,
|
|
read: z.boolean().default(false),
|
|
archived: z.boolean().default(false),
|
|
created_at: z.string().default(""),
|
|
details: z.record(z.string(), z.string()).nullable().default(null),
|
|
}).loose();
|
|
|
|
export const InboxListSchema = z.array(InboxItemSchema).default([]);
|
|
export const EMPTY_INBOX_LIST: InboxItem[] = [];
|
|
|
|
export const MemberWithUserSchema: z.ZodType<MemberWithUser> = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string().default(""),
|
|
user_id: z.string().default(""),
|
|
role: z.enum(["owner", "admin", "member"]).catch("member"),
|
|
created_at: z.string().default(""),
|
|
name: z.string().default(""),
|
|
email: z.string().default(""),
|
|
avatar_url: z.string().nullable().default(null),
|
|
}).loose();
|
|
|
|
export const MemberListSchema = z.array(MemberWithUserSchema).default([]);
|
|
export const EMPTY_MEMBER_LIST: MemberWithUser[] = [];
|
|
|
|
const AgentInvocationTargetSchema: z.ZodType<AgentInvocationTarget> = z
|
|
.object({
|
|
target_type: z.enum(["workspace", "member", "team"]).catch("team"),
|
|
target_id: z
|
|
.string()
|
|
.nullable()
|
|
.optional()
|
|
.catch(null)
|
|
.transform((v) => v ?? null),
|
|
})
|
|
.loose();
|
|
|
|
// Agent schema is loose on every enum / structural field — the agent table is
|
|
// where new modes/visibilities/statuses get added most often. We need only id,
|
|
// name, avatar_url, and a couple of flags for the assignee picker + chat
|
|
// header; everything else is informational and safe to default.
|
|
export const AgentSchema: z.ZodType<Agent> = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string().default(""),
|
|
runtime_id: z.string().default(""),
|
|
name: z.string().default(""),
|
|
description: z.string().default(""),
|
|
instructions: z.string().default(""),
|
|
avatar_url: z.string().nullable().default(null),
|
|
runtime_mode: z.string().catch("daemon") as unknown as z.ZodType<
|
|
Agent["runtime_mode"]
|
|
>,
|
|
runtime_config: z.record(z.string(), z.unknown()).default({}),
|
|
custom_args: z.array(z.string()).default([]),
|
|
// MUL-2600: agent resource shape no longer carries custom_env or
|
|
// custom_env_redacted. Mobile keeps only the coarse metadata that
|
|
// mirrors web's expectations. Real env values are reachable via the
|
|
// dedicated /env endpoint and we don't expose env editing on mobile.
|
|
has_custom_env: z.boolean().optional(),
|
|
custom_env_key_count: z.number().optional(),
|
|
visibility: z.string().catch("workspace") as unknown as z.ZodType<
|
|
Agent["visibility"]
|
|
>,
|
|
permission_mode: z.enum(["private", "public_to"]).catch("private"),
|
|
invocation_targets: z.array(AgentInvocationTargetSchema).default([]),
|
|
status: z.string().catch("active") as unknown as z.ZodType<Agent["status"]>,
|
|
max_concurrent_tasks: z.number().default(1),
|
|
model: z.string().default(""),
|
|
owner_id: z.string().nullable().default(null),
|
|
skills: z.array(z.unknown()).default([]) as unknown as z.ZodType<
|
|
Agent["skills"]
|
|
>,
|
|
created_at: z.string().default(""),
|
|
updated_at: z.string().default(""),
|
|
archived_at: z.string().nullable().default(null),
|
|
archived_by: z.string().nullable().default(null),
|
|
}).loose();
|
|
|
|
export const AgentListSchema = z.array(AgentSchema).default([]);
|
|
export const EMPTY_AGENT_LIST: Agent[] = [];
|
|
|
|
// Runtime device — the daemon (local or cloud) an agent binds to. Mobile reads
|
|
// it for the presence dot: `status` + `last_seen_at` drive the three-state
|
|
// availability derivation in @multica/core/agents/derive-presence. All other
|
|
// fields default safely so a backend that adds optional new metadata
|
|
// (timezone, visibility flags, etc.) doesn't break the parse.
|
|
export const RuntimeSchema: z.ZodType<RuntimeDevice> = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string().default(""),
|
|
daemon_id: z.string().nullable().default(null),
|
|
name: z.string().default(""),
|
|
runtime_mode: z.string().catch("local") as unknown as z.ZodType<
|
|
RuntimeDevice["runtime_mode"]
|
|
>,
|
|
provider: z.string().default(""),
|
|
launch_header: z.string().default(""),
|
|
// The two fields presence derivation actually reads. Status defaults to
|
|
// "offline" — a runtime row with an unparseable status is treated as
|
|
// unreachable, which is the safe degrade for the dot.
|
|
status: z.enum(["online", "offline"]).catch("offline"),
|
|
last_seen_at: z.string().nullable().default(null),
|
|
device_info: z.string().default(""),
|
|
metadata: z.record(z.string(), z.unknown()).default({}),
|
|
owner_id: z.string().nullable().default(null),
|
|
visibility: z.string().catch("private") as unknown as z.ZodType<
|
|
RuntimeDevice["visibility"]
|
|
>,
|
|
timezone: z.string().default(""),
|
|
created_at: z.string().default(""),
|
|
updated_at: z.string().default(""),
|
|
}).loose();
|
|
|
|
export const RuntimeListSchema = z.array(RuntimeSchema).default([]);
|
|
export const EMPTY_RUNTIME_LIST: RuntimeDevice[] = [];
|
|
|
|
// Squad schema — fields mobile actually consumes for the @mention suggestion
|
|
// bar (id, name, archived_at filter) plus identity/timestamp fields that are
|
|
// safe to default. `.loose()` so the server can add squad fields without
|
|
// breaking the parser.
|
|
export const SquadSchema: z.ZodType<Squad> = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string().default(""),
|
|
name: z.string().default(""),
|
|
description: z.string().default(""),
|
|
instructions: z.string().default(""),
|
|
avatar_url: z.string().nullable().default(null),
|
|
leader_id: z.string().default(""),
|
|
creator_id: z.string().default(""),
|
|
created_at: z.string().default(""),
|
|
updated_at: z.string().default(""),
|
|
archived_at: z.string().nullable().default(null),
|
|
archived_by: z.string().nullable().default(null),
|
|
}).loose();
|
|
|
|
export const SquadListSchema = z.array(SquadSchema).default([]);
|
|
export const EMPTY_SQUAD_LIST: Squad[] = [];
|
|
|
|
// Single-issue fallback used by getIssue. Mobile reuses IssueSchema from core
|
|
// for parsing; this sentinel lets parseWithFallback yield a structurally-
|
|
// valid Issue when the response drifts. `id: ""` flags drift downstream — the
|
|
// detail screen treats it as "issue not found" and shows the empty state.
|
|
export const EMPTY_ISSUE_FALLBACK: import("@multica/core/types").Issue = {
|
|
id: "",
|
|
workspace_id: "",
|
|
number: 0,
|
|
identifier: "",
|
|
title: "",
|
|
description: null,
|
|
status: "backlog",
|
|
priority: "none",
|
|
assignee_type: null,
|
|
assignee_id: null,
|
|
creator_type: "member",
|
|
creator_id: "",
|
|
parent_issue_id: null,
|
|
project_id: null,
|
|
position: 0,
|
|
stage: null,
|
|
start_date: null,
|
|
due_date: null,
|
|
metadata: {},
|
|
properties: {},
|
|
created_at: "",
|
|
updated_at: "",
|
|
};
|
|
|
|
// Helpers re-exported for ergonomic single-import at the call site.
|
|
export type { Label, Project, ProjectResource };
|