Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/desktop/src/renderer/src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { DashboardPage } from "@multica/views/dashboard";
import { AutopilotsPage } from "@multica/views/autopilots/components";
import { MyIssuesPage } from "@multica/views/my-issues";
import { TasksPage } from "@multica/views/tasks";
import { DraftsPage } from "@multica/views/drafts";
import { ShipPage, ShipReleasePage } from "@multica/views/ship";
import { SkillsPage } from "@multica/views/skills";
import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
Expand Down Expand Up @@ -165,6 +166,17 @@ export const appRoutes: RouteObject[] = [
element: <TaskDetailPage />,
handle: { title: "Task" },
},
{
// Drafts master-detail. Selection rides on ?draft=<id> (mirrors
// tasks), so there's no separate /drafts/:id route.
path: "drafts",
element: (
<ErrorBoundary>
<DraftsPage />
</ErrorBoundary>
),
handle: { title: "Drafts" },
},
{
path: "ship",
element: <ShipPage />,
Expand Down
12 changes: 12 additions & 0 deletions apps/web/app/[workspaceSlug]/(dashboard)/drafts/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"use client";

import { DraftsPage } from "@multica/views/drafts";
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";

export default function Page() {
return (
<ErrorBoundary>
<DraftsPage />
</ErrorBoundary>
);
}
54 changes: 54 additions & 0 deletions packages/core/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import type {
ListTasksResponse,
CreateTaskRequest,
UpdateTaskRequest,
Draft,
ListDraftsParams,
ListDraftsResponse,
CreateDraftRequest,
UpdateDraftRequest,
CreateIssueRequest,
UpdateIssueRequest,
ListIssuesResponse,
Expand Down Expand Up @@ -294,6 +299,10 @@ import {
EMPTY_MCP_SERVER_DIRECTORY_RESPONSE,
ListTasksResponseSchema,
EMPTY_LIST_TASKS_RESPONSE,
DraftSchema,
ListDraftsResponseSchema,
EMPTY_LIST_DRAFTS_RESPONSE,
EMPTY_DRAFT,
} from "./schemas";

/** Identifies the calling client to the server.
Expand Down Expand Up @@ -791,6 +800,51 @@ export class ApiClient {
return this.fetch(`/api/tasks/${id}/promote`, { method: "POST" });
}

// Drafts. Standalone workspace- + owner-scoped markdown documents (slice 0
// of the Drafts feature). Every read goes through parseWithFallback so a
// server-side shape drift — an older desktop build talking to a newer
// backend — surfaces as the fallback rather than a white-screen. No bare
// `as` on any response body.
async listDrafts(params?: ListDraftsParams): Promise<ListDraftsResponse> {
const search = new URLSearchParams();
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
const raw = await this.fetch<unknown>(`/api/drafts?${search}`);
return parseWithFallback(raw, ListDraftsResponseSchema, EMPTY_LIST_DRAFTS_RESPONSE, {
endpoint: "GET /api/drafts",
});
}

async getDraft(id: string): Promise<Draft> {
const raw = await this.fetch<unknown>(`/api/drafts/${id}`);
return parseWithFallback(raw, DraftSchema, EMPTY_DRAFT, {
endpoint: "GET /api/drafts/:id",
});
}

async createDraft(data: CreateDraftRequest): Promise<Draft> {
const raw = await this.fetch<unknown>("/api/drafts", {
method: "POST",
body: JSON.stringify(data),
});
return parseWithFallback(raw, DraftSchema, EMPTY_DRAFT, {
endpoint: "POST /api/drafts",
});
}

async updateDraft(id: string, data: UpdateDraftRequest): Promise<Draft> {
const raw = await this.fetch<unknown>(`/api/drafts/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
return parseWithFallback(raw, DraftSchema, EMPTY_DRAFT, {
endpoint: "PATCH /api/drafts/:id",
});
}

async deleteDraft(id: string): Promise<void> {
await this.fetch(`/api/drafts/${id}`, { method: "DELETE" });
}

async batchUpdateIssues(issueIds: string[], updates: UpdateIssueRequest): Promise<{ updated: number }> {
return this.fetch("/api/issues/batch-update", {
method: "POST",
Expand Down
49 changes: 49 additions & 0 deletions packages/core/api/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
DashboardUsageDailyListSchema,
ListIssuesResponseSchema,
PromoteDeployEnvironmentResponseSchema,
DraftSchema,
ListDraftsResponseSchema,
} from "./schemas";

const baseIssue = {
Expand Down Expand Up @@ -110,3 +112,50 @@ describe("PromoteDeployEnvironmentResponseSchema (deploy-to-production)", () =>
).toBe(false);
});
});

// Drafts is a standalone entity behind parseWithFallback on every read. These
// tests pin the contract the schema must hold (API Response Compatibility):
// a malformed row fails closed so the client returns its fallback, an unknown
// status value parses (open enum / enum-drift), and a missing field is caught.
const baseDraft = {
id: "33333333-3333-3333-3333-333333333333",
workspace_id: "ws-1",
owner_user_id: "user-1",
title: "Draft title",
body: "# body",
status: "draft",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};

describe("DraftSchema / ListDraftsResponseSchema drift", () => {
it("accepts a well-formed draft and an unknown status value (open enum)", () => {
// A future server value like "accepted" — or anything the frontend has
// never seen — must parse, not crash, so the UI can render a generic
// fallback for it.
const parsed = DraftSchema.parse({ ...baseDraft, status: "some_future_status" });
expect(parsed.status).toBe("some_future_status");
});

it("rejects a draft missing a required field so parseWithFallback falls back", () => {
const { title: _omit, ...draftMissingTitle } = baseDraft;
expect(DraftSchema.safeParse(draftMissingTitle).success).toBe(false);
});

it("rejects a wrong-typed body (number) so the client returns EMPTY_DRAFT", () => {
expect(DraftSchema.safeParse({ ...baseDraft, body: 123 }).success).toBe(false);
});

it("defaults a null/missing drafts array to [] instead of throwing", () => {
// An older/newer backend that omits the wrapper array must degrade to an
// empty list, never a non-iterable that crashes the list page.
const parsed = ListDraftsResponseSchema.parse({ total: 0 });
expect(parsed.drafts).toEqual([]);
});

it("rejects a non-array 'drafts' so parseWithFallback can return its fallback", () => {
expect(
ListDraftsResponseSchema.safeParse({ drafts: "nope", total: 0 }).success,
).toBe(false);
});
});
44 changes: 44 additions & 0 deletions packages/core/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import type {
TimelineEntry,
User,
ListTasksResponse,
Draft,
ListDraftsResponse,
} from "../types";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -343,6 +345,48 @@ export const EMPTY_LIST_TASKS_RESPONSE: ListTasksResponse = {
total: 0,
};

// Drafts. Standalone entity (its own table — not an issue row). Same leniency
// rules as the schemas above: `status` is an open enum kept as `z.string()` so
// a future server value (e.g. "accepted") parses instead of crashing,
// optionals are nullable-tolerant, `.loose()` lets the server add fields later
// without stripping them. Reads go through parseWithFallback at the client
// boundary so an older desktop build talking to a newer backend degrades
// gracefully rather than white-screening.
export const DraftSchema = z.object({
id: z.string(),
workspace_id: z.string(),
owner_user_id: z.string(),
title: z.string(),
body: z.string(),
status: z.string(),
created_at: z.string(),
updated_at: z.string(),
}).loose();

export const ListDraftsResponseSchema = z.object({
drafts: z.array(DraftSchema).default([]),
total: z.number().default(0),
}).loose();

export const EMPTY_LIST_DRAFTS_RESPONSE: ListDraftsResponse = {
drafts: [],
total: 0,
};

// Fallback for the single-draft endpoints (get/create/update). A validation
// failure here is the rare worst case (badly-drifted backend); the empty draft
// keeps the editor rendering rather than throwing into the UI.
export const EMPTY_DRAFT: Draft = {
id: "",
workspace_id: "",
owner_user_id: "",
title: "",
body: "",
status: "draft",
created_at: "",
updated_at: "",
};

const SubscriberSchema = z.object({
issue_id: z.string(),
user_type: z.string(),
Expand Down
74 changes: 74 additions & 0 deletions packages/core/drafts/cache-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { QueryClient } from "@tanstack/react-query";
import type { Draft, ListDraftsResponse } from "../types";
import { draftKeys } from "./queries";

/**
* Drafts live in a flat, newest-edited-first list, so cache patching is array
* splicing. All helpers operate on the {@link ListDraftsResponse} shape that
* {@link draftListOptions}'s queryFn returns — the `select` projection that
* extracts `.drafts` is applied downstream (TanStack re-runs select after every
* cache write).
*
* Helpers iterate `qc.setQueriesData` over the `lists` prefix so any mounted
* list cache stays consistent.
*/

export function prependDraftToLists(qc: QueryClient, wsId: string, draft: Draft): void {
qc.setQueriesData<ListDraftsResponse>(
{ queryKey: draftKeys.lists(wsId) },
(old) => {
if (!old) return old;
// Defensive: if the same id is already present (WS event landed first),
// don't double-insert.
if (old.drafts.some((d) => d.id === draft.id)) return old;
return { ...old, drafts: [draft, ...old.drafts], total: old.total + 1 };
},
);
}

export function replaceDraftInLists(qc: QueryClient, wsId: string, draft: Draft): void {
qc.setQueriesData<ListDraftsResponse>(
{ queryKey: draftKeys.lists(wsId) },
(old) => {
if (!old) return old;
let touched = false;
const drafts = old.drafts.map((d) => {
if (d.id !== draft.id) return d;
touched = true;
return draft;
});
// No-op write if id wasn't in this cache — preserves referential
// identity, keeps React from re-rendering subscribers unnecessarily.
return touched ? { ...old, drafts } : old;
},
);
}

export function removeDraftFromLists(qc: QueryClient, wsId: string, draftId: string): void {
qc.setQueriesData<ListDraftsResponse>(
{ queryKey: draftKeys.lists(wsId) },
(old) => {
if (!old) return old;
const before = old.drafts.length;
const drafts = old.drafts.filter((d) => d.id !== draftId);
if (drafts.length === before) return old;
return { ...old, drafts, total: Math.max(0, old.total - 1) };
},
);
}

/**
* Replace a placeholder (temp) draft with the server-confirmed one. Used by the
* optimistic create flow: insert with a temp id while the request is in flight,
* then swap when the real row comes back.
*/
export function swapTempDraft(qc: QueryClient, wsId: string, tempId: string, draft: Draft): void {
qc.setQueriesData<ListDraftsResponse>(
{ queryKey: draftKeys.lists(wsId) },
(old) => {
if (!old) return old;
const drafts = old.drafts.map((d) => (d.id === tempId ? draft : d));
return { ...old, drafts };
},
);
}
14 changes: 14 additions & 0 deletions packages/core/drafts/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export { draftKeys, draftListOptions, draftDetailOptions } from "./queries";
export {
useCreateDraft,
useUpdateDraft,
useDeleteDraft,
isTempDraftId,
} from "./mutations";
export {
prependDraftToLists,
replaceDraftInLists,
removeDraftFromLists,
swapTempDraft,
} from "./cache-helpers";
export { onDraftCreated, onDraftUpdated, onDraftDeleted } from "./ws-updaters";
Loading
Loading