diff --git a/.env.example b/.env.example index 27500735d60..c185eb4b658 100644 --- a/.env.example +++ b/.env.example @@ -109,6 +109,13 @@ MULTICA_CODEX_TIMEOUT=20m # or any variant string). The env override beats the YAML, which is the # Ops kill-switch path — flip a flag without redeploying by restarting the # process with the env var set. +# +# Release gate (MUL-4809 §4.1, delete after full rollout): finalize create_issue +# autopilot runs off task outcome instead of issue status. Default OFF (legacy). +# Two-phase deploy: ship this binary with the gate off and drain old pods, then +# restart with FF_AUTOPILOT_TASK_DRIVEN_RUNS=true so all (guarded-SQL) pods switch +# to task-driven finalization together. +# FF_AUTOPILOT_TASK_DRIVEN_RUNS=false MULTICA_FEATURE_FLAGS_FILE= # Self-host image channel diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index 00971a7318d..9520549a868 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -90,6 +90,10 @@ import type { IssueProperty, IssuePropertyValue, CreatePropertyRequest, + CreateIssueStatusRequest, + UpdateIssueStatusRequest, + IssueStatusDefinition, + IssueStatusCatalog, UpdatePropertyRequest, ListPropertiesResponse, IssuePropertiesResponse, @@ -250,9 +254,13 @@ import { LabelSchema, ListLabelsResponseSchema, IssuePropertySchema, + IssueStatusDefinitionSchema, + IssueStatusCatalogSchema, ListPropertiesResponseSchema, IssuePropertiesResponseSchema, EMPTY_ISSUE_PROPERTY, + EMPTY_ISSUE_STATUS_DEFINITION, + EMPTY_ISSUE_STATUS_CATALOG, EMPTY_LIST_PROPERTIES_RESPONSE, EMPTY_ISSUE_PROPERTIES_RESPONSE, ResourceLabelsResponseSchema, @@ -564,7 +572,10 @@ export class ApiClient { if (params?.workspace_id) search.set("workspace_id", params.workspace_id); if (params?.q?.trim()) search.set("q", params.q.trim()); if (params?.status) search.set("status", params.status); + if (params?.status_id) search.set("status_id", params.status_id); + if (params?.status_category) search.set("status_category", params.status_category); if (params?.statuses?.length) search.set("statuses", params.statuses.join(",")); + if (params?.status_ids?.length) search.set("status_ids", params.status_ids.join(",")); if (params?.priority) search.set("priority", params.priority); if (params?.priorities?.length) search.set("priorities", params.priorities.join(",")); if (params?.assignee_id) search.set("assignee_id", params.assignee_id); @@ -627,6 +638,9 @@ export class ApiClient { if (params.offset) search.set("offset", String(params.offset)); if (params.workspace_id) search.set("workspace_id", params.workspace_id); if (params.statuses?.length) search.set("statuses", params.statuses.join(",")); + if (params.status_ids?.length) search.set("status_ids", params.status_ids.join(",")); + if (params.status_id) search.set("status_id", params.status_id); + if (params.status_category) search.set("status_category", params.status_category); if (params.priorities?.length) search.set("priorities", params.priorities.join(",")); if (params.assignee_types?.length) search.set("assignee_types", params.assignee_types.join(",")); if (params.assignee_id) search.set("assignee_id", params.assignee_id); @@ -2290,6 +2304,66 @@ export class ApiClient { }); } + // ── Issue status catalog (MUL-4809 §5) ──────────────────────────────────── + + /** + * The workspace status catalog plus its alias table. Readable by any member; + * `include_archived` is admin-only and 403s otherwise. + * + * A backend predating custom statuses 404s here (installed desktop clients can + * outrun the server). That degrades to an empty catalog, which every surface + * reads as "not loaded" and falls back to the legacy 7 status tokens — so an + * old server never breaks the issue UI. + */ + async listIssueStatuses(includeArchived = false): Promise { + const suffix = includeArchived ? "?include_archived=true" : ""; + let raw: unknown; + try { + raw = await this.fetch(`/api/issue-statuses${suffix}`); + } catch (error) { + if (error instanceof Error && "status" in error && (error as { status?: number }).status === 404) { + return EMPTY_ISSUE_STATUS_CATALOG; + } + throw error; + } + return parseWithFallback(raw, IssueStatusCatalogSchema, EMPTY_ISSUE_STATUS_CATALOG, { + endpoint: "GET /api/issue-statuses", + }); + } + + async createIssueStatus(data: CreateIssueStatusRequest): Promise { + const raw = await this.fetch(`/api/issue-statuses`, { + method: "POST", + body: JSON.stringify(data), + }); + return parseWithFallback(raw, IssueStatusDefinitionSchema, EMPTY_ISSUE_STATUS_DEFINITION, { + endpoint: "POST /api/issue-statuses", + }); + } + + async updateIssueStatus(id: string, data: UpdateIssueStatusRequest): Promise { + const raw = await this.fetch(`/api/issue-statuses/${id}`, { + method: "PATCH", + body: JSON.stringify(data), + }); + return parseWithFallback(raw, IssueStatusDefinitionSchema, EMPTY_ISSUE_STATUS_DEFINITION, { + endpoint: "PATCH /api/issue-statuses/{id}", + }); + } + + /** + * Archives (soft-deletes) a custom status. When the status still has issues, + * the server requires `migrateToStatusId` — a non-archived status in the SAME + * Category — and moves those issues over in the same transaction. System + * statuses cannot be archived. + */ + async archiveIssueStatus(id: string, migrateToStatusId?: string): Promise { + const suffix = migrateToStatusId + ? `?migrate_to_status_id=${encodeURIComponent(migrateToStatusId)}` + : ""; + await this.fetch(`/api/issue-statuses/${id}${suffix}`, { method: "DELETE" }); + } + async updateProperty(id: string, data: UpdatePropertyRequest): Promise { const raw = await this.fetch(`/api/properties/${id}`, { method: "PATCH", diff --git a/packages/core/api/schema.test.ts b/packages/core/api/schema.test.ts index a14466a7e36..e14d5af5021 100644 --- a/packages/core/api/schema.test.ts +++ b/packages/core/api/schema.test.ts @@ -91,6 +91,122 @@ describe("ApiClient schema fallback", () => { }); }); + describe("listIssues status_detail (MUL-4809)", () => { + // Minimal valid issue row (only the non-defaulted fields). + const baseIssue = { + id: "issue-1", + workspace_id: "ws-1", + number: 1, + identifier: "MUL-1", + title: "Test", + description: null, + status: "todo", + priority: "none", + assignee_type: null, + assignee_id: null, + creator_type: "member", + creator_id: "user-1", + parent_issue_id: null, + project_id: null, + position: 1, + start_date: null, + due_date: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }; + + it("parses an old-server issue that omits status_id / status_detail", async () => { + // Older backends predate MUL-4809 and send neither field. Must parse, not + // fall back, and leave the client on the legacy `status` token. + stubFetchJson({ issues: [baseIssue], total: 1 }); + const client = new ApiClient("https://api.example.test"); + const res = await client.listIssues(); + expect(res.issues).toHaveLength(1); + expect(res.issues[0]?.status_id).toBeUndefined(); + expect(res.issues[0]?.status_detail).toBeUndefined(); + expect(res.issues[0]?.status).toBe("todo"); + }); + + it("passes a well-formed status_id / status_detail through", async () => { + stubFetchJson({ + issues: [ + { + ...baseIssue, + status_id: "st-1", + status_detail: { + id: "st-1", + name: "In Review", + category: "in_progress", + icon: "in_review", + color: "success", + }, + }, + ], + total: 1, + }); + const client = new ApiClient("https://api.example.test"); + const res = await client.listIssues(); + expect(res.issues[0]?.status_id).toBe("st-1"); + expect(res.issues[0]?.status_detail?.name).toBe("In Review"); + expect(res.issues[0]?.status_detail?.category).toBe("in_progress"); + }); + + it("degrades status_detail to null on an unknown category, keeping the issue", async () => { + // category is a closed 5-value set. An unknown value must fail only the + // status_detail field (-> null), never blank the issue or the whole list. + stubFetchJson({ + issues: [ + { + ...baseIssue, + status_id: "st-1", + status_detail: { + id: "st-1", + name: "Weird", + category: "some_future_category", + icon: "todo", + color: "warning", + }, + }, + ], + total: 1, + }); + const client = new ApiClient("https://api.example.test"); + const res = await client.listIssues(); + expect(res.issues).toHaveLength(1); + expect(res.issues[0]?.status).toBe("todo"); + expect(res.issues[0]?.status_detail).toBeNull(); + }); + + it("degrades a malformed status_detail to null without blanking the list (§7.3)", async () => { + // A wrong-typed subfield used to fail the outer IssueSchema and drop the + // whole batch to []. Now it degrades to just status_detail = null. + stubFetchJson({ + issues: [ + { ...baseIssue, id: "issue-1", status_detail: { id: 123, name: null } }, + { ...baseIssue, id: "issue-2" }, + ], + total: 2, + }); + const client = new ApiClient("https://api.example.test"); + const res = await client.listIssues(); + expect(res.issues).toHaveLength(2); + expect(res.issues[0]?.status_detail).toBeNull(); + expect(res.issues[0]?.status).toBe("todo"); + }); + + it("keeps null on explicit-null status_id / status_detail", async () => { + stubFetchJson({ + issues: [{ ...baseIssue, status_id: null, status_detail: null }], + total: 1, + }); + const client = new ApiClient("https://api.example.test"); + const res = await client.listIssues(); + expect(res.issues).toHaveLength(1); + expect(res.issues[0]?.status_id).toBeNull(); + expect(res.issues[0]?.status_detail).toBeNull(); + }); + }); + describe("createIssue", () => { // The create modal decides whether to run its label-attach fallback by // reading `labels` off the parsed response, and treats a rejection as a diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts index 2b60a355421..3a3e1037084 100644 --- a/packages/core/api/schemas.ts +++ b/packages/core/api/schemas.ts @@ -23,6 +23,8 @@ import type { InboxWorkspaceUnread, Label, IssueProperty, + IssueStatusDefinition, + IssueStatusCatalog, ListPropertiesResponse, IssuePropertiesResponse, ListIssuesResponse, @@ -435,6 +437,82 @@ export const IssueTriggerPreviewSchema = z.object({ // to {} so consumers never need to nil-guard `issue.metadata`. const IssueMetadataSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({}); +// Resolved catalog view of an issue's status (MUL-4809). Unlike the open string +// enums elsewhere in this file, `category` is a CLOSED 5-value set by product +// design (no 6th Category is ever added), so it is a strict enum here — that +// keeps the public StatusDetail.category union honest, and a malformed/unknown +// value fails this schema and is degraded to null at the field (see IssueSchema), +// never blanking the whole issue. `.loose()` still preserves extra server fields. +export const StatusDetailSchema = z.object({ + id: z.string(), + name: z.string(), + category: z.enum(["backlog", "todo", "in_progress", "done", "cancelled"]), + icon: z.string(), + color: z.string(), +}).loose(); + +/** + * A catalog entry (MUL-4809 §5). `category` is the strict 5-value enum for the + * same reason as StatusDetail: it is the only machine semantics and the union + * must stay honest. `icon` / `color` stay lenient strings — the server + * allowlists them, but a future token must not blank the whole row; every + * render path resolves an unknown value to a safe fallback. + */ +export const IssueStatusDefinitionSchema = z.object({ + id: z.string(), + workspace_id: z.string().optional().default(""), + name: z.string(), + description: z.string().optional().default(""), + icon: z.string().optional().default("todo"), + color: z.string().optional().default("muted-foreground"), + category: z.enum(["backlog", "todo", "in_progress", "done", "cancelled"]), + system_key: z.string().nullable().optional().default(null), + is_system: z.boolean().optional().default(false), + is_default: z.boolean().optional().default(false), + position: z.number().optional().default(0), + archived: z.boolean().optional().default(false), + archived_at: z.string().nullable().optional().default(null), + created_at: z.string().optional().default(""), + updated_at: z.string().optional().default(""), +}).loose(); + +export const EMPTY_ISSUE_STATUS_DEFINITION: IssueStatusDefinition = { + id: "", + workspace_id: "", + name: "", + description: "", + icon: "todo", + color: "muted-foreground", + category: "todo", + system_key: null, + is_system: false, + is_default: false, + position: 0, + archived: false, + archived_at: null, + created_at: "", + updated_at: "", +}; + +export const IssueStatusCatalogSchema = z.object({ + statuses: z.array(IssueStatusDefinitionSchema).default([]), + category_defaults: z.record(z.string(), z.string()).default({}), + aliases: z.record(z.string(), z.string()).default({}), + total: z.number().default(0), +}).loose(); + +/** + * Fallback for a malformed catalog response. Empty rather than the 7 built-ins: + * callers treat an empty catalog as "not loaded yet" and keep rendering issues + * off the legacy `status` token, which is always present. + */ +export const EMPTY_ISSUE_STATUS_CATALOG: IssueStatusCatalog = { + statuses: [], + category_defaults: {}, + aliases: {}, + total: 0, +}; + export const IssueSchema = z.object({ id: z.string(), workspace_id: z.string(), @@ -462,6 +540,14 @@ export const IssueSchema = z.object({ properties: IssuePropertyValuesSchema, reactions: z.array(z.unknown()).optional(), labels: z.array(z.unknown()).optional(), + // Custom-status catalog fields (MUL-4809). Optional (absent on older backends + // and on endpoints that don't hydrate them) + nullable (null when the issue has + // no status_id yet). `.catch(null)` isolates malformed data to THIS field — + // a bad status_id/status_detail degrades to null and the issue (and the whole + // list) still parses, per the §7.3 safe-degrade rule — the client then falls + // back to the legacy `status` token. + status_id: z.string().nullish().catch(null), + status_detail: StatusDetailSchema.nullish().catch(null), created_at: z.string(), updated_at: z.string(), }).loose(); diff --git a/packages/core/issue-statuses/index.ts b/packages/core/issue-statuses/index.ts new file mode 100644 index 00000000000..837ff6ad48f --- /dev/null +++ b/packages/core/issue-statuses/index.ts @@ -0,0 +1,6 @@ +export { issueStatusKeys, issueStatusCatalogOptions, issueStatusListOptions } from "./queries"; +export { + useCreateIssueStatus, + useUpdateIssueStatus, + useArchiveIssueStatus, +} from "./mutations"; diff --git a/packages/core/issue-statuses/mutations.ts b/packages/core/issue-statuses/mutations.ts new file mode 100644 index 00000000000..2bba8ae48a0 --- /dev/null +++ b/packages/core/issue-statuses/mutations.ts @@ -0,0 +1,56 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "../api"; +import { issueStatusKeys } from "./queries"; +import { useWorkspaceId } from "../hooks"; +import { issueKeys } from "../issues/queries"; +import type { CreateIssueStatusRequest, UpdateIssueStatusRequest } from "../types"; + +/** + * Catalog mutations (MUL-4809 §5). None of these patch optimistically: edits + * happen in the settings tab where a round-trip is acceptable, and the server + * canonicalizes the parts that matter (position, the one-default-per-Category + * invariant, archive-with-migration). + * + * Every mutation invalidates the ISSUE caches too, because issue rows embed + * `status_detail` — a rename/recolor changes how existing rows render, and an + * archive can move issues to another status entirely. + */ +function useCatalogInvalidation() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return () => { + qc.invalidateQueries({ queryKey: issueStatusKeys.all(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.all(wsId) }); + }; +} + +export function useCreateIssueStatus() { + const invalidate = useCatalogInvalidation(); + return useMutation({ + mutationFn: (data: CreateIssueStatusRequest) => api.createIssueStatus(data), + onSettled: invalidate, + }); +} + +export function useUpdateIssueStatus() { + const invalidate = useCatalogInvalidation(); + return useMutation({ + mutationFn: ({ id, ...data }: { id: string } & UpdateIssueStatusRequest) => + api.updateIssueStatus(id, data), + onSettled: invalidate, + }); +} + +/** + * Archive a custom status. `migrateToStatusId` is required by the server when + * the status still has issues; it must name a non-archived status in the SAME + * Category, and the issues move over in the same transaction. + */ +export function useArchiveIssueStatus() { + const invalidate = useCatalogInvalidation(); + return useMutation({ + mutationFn: ({ id, migrateToStatusId }: { id: string; migrateToStatusId?: string }) => + api.archiveIssueStatus(id, migrateToStatusId), + onSettled: invalidate, + }); +} diff --git a/packages/core/issue-statuses/queries.ts b/packages/core/issue-statuses/queries.ts new file mode 100644 index 00000000000..b073dd244cf --- /dev/null +++ b/packages/core/issue-statuses/queries.ts @@ -0,0 +1,33 @@ +import { queryOptions } from "@tanstack/react-query"; +import { api } from "../api"; + +export const issueStatusKeys = { + /** PREFIX for invalidation — covers every catalog variant for the workspace. */ + all: (wsId: string) => ["issue-statuses", wsId] as const, + /** FULL KEY */ + catalog: (wsId: string, includeArchived = false) => + [...issueStatusKeys.all(wsId), "catalog", includeArchived] as const, +}; + +/** + * The workspace status catalog (MUL-4809). Server state only — never mirrored + * into Zustand, per the state rules: the catalog is workspace data that changes + * from the settings page and from other clients over websocket. + * + * `includeArchived` is admin-only server-side; leave it false for the pickers + * and filters, which must only ever offer active statuses. + */ +export function issueStatusCatalogOptions(wsId: string, includeArchived = false) { + return queryOptions({ + queryKey: issueStatusKeys.catalog(wsId, includeArchived), + queryFn: () => api.listIssueStatuses(includeArchived), + }); +} + +/** Just the ordered status list — the common case for pickers and filters. */ +export function issueStatusListOptions(wsId: string, includeArchived = false) { + return queryOptions({ + ...issueStatusCatalogOptions(wsId, includeArchived), + select: (data) => data.statuses, + }); +} diff --git a/packages/core/issues/config/index.ts b/packages/core/issues/config/index.ts index 60d97c53f51..d562d21109a 100644 --- a/packages/core/issues/config/index.ts +++ b/packages/core/issues/config/index.ts @@ -1,2 +1,3 @@ -export { STATUS_ORDER, ALL_STATUSES, STATUS_CONFIG } from "./status"; +export { STATUS_ORDER, ALL_STATUSES, STATUS_CONFIG, STATUS_COLOR_CONFIG, statusThemeForColor, statusTheme } from "./status"; +export type { StatusTheme } from "./status"; export { PRIORITY_ORDER, PRIORITY_CONFIG } from "./priority"; diff --git a/packages/core/issues/config/status.ts b/packages/core/issues/config/status.ts index 185b7980b23..d0e976ba8f9 100644 --- a/packages/core/issues/config/status.ts +++ b/packages/core/issues/config/status.ts @@ -1,4 +1,4 @@ -import type { IssueStatus } from "../../types"; +import type { IssueStatus, StatusColor, StatusDetail } from "../../types"; export const STATUS_ORDER: IssueStatus[] = [ "backlog", @@ -38,3 +38,54 @@ export const STATUS_CONFIG: Record< blocked: { label: "Blocked", iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10", dividerColor: "bg-destructive", columnBg: "bg-destructive/5" }, cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent", dividerColor: "bg-muted-foreground/40", columnBg: "bg-muted/40" }, }; + +export interface StatusTheme { + iconColor: string; + hoverBg: string; + dividerColor: string; + columnBg: string; +} + +/** + * Theme classes keyed by the semantic COLOR token a catalog status carries + * (MUL-4809). STATUS_CONFIG above is keyed by the 7 legacy status tokens, which + * cannot express a custom status; this map is the color-driven equivalent and is + * what every custom-status-aware surface should use. + * + * The class strings are written out in full on purpose: Tailwind scans source + * statically, so a template-built class name (`text-${color}`) would be purged. + */ +export const STATUS_COLOR_CONFIG: Record = { + "muted-foreground": { iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent", dividerColor: "bg-muted-foreground/40", columnBg: "bg-muted/40" }, + warning: { iconColor: "text-warning", hoverBg: "hover:bg-warning/10", dividerColor: "bg-warning", columnBg: "bg-warning/5" }, + success: { iconColor: "text-success", hoverBg: "hover:bg-success/10", dividerColor: "bg-success", columnBg: "bg-success/5" }, + info: { iconColor: "text-info", hoverBg: "hover:bg-info/10", dividerColor: "bg-info", columnBg: "bg-info/5" }, + destructive: { iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10", dividerColor: "bg-destructive", columnBg: "bg-destructive/5" }, +}; + +const FALLBACK_STATUS_THEME: StatusTheme = STATUS_COLOR_CONFIG["muted-foreground"]; + +/** + * Theme classes for a color token. An unknown token (a newer server shipping a + * color this client predates) degrades to the neutral theme rather than + * rendering unstyled — the same tolerance the schemas apply. + */ +export function statusThemeForColor(color: string | null | undefined): StatusTheme { + if (!color) return FALLBACK_STATUS_THEME; + return STATUS_COLOR_CONFIG[color as StatusColor] ?? FALLBACK_STATUS_THEME; +} + +/** + * Theme classes for an issue's status. Prefers the resolved catalog entry + * (`status_detail`, which is what a custom status arrives as) and falls back to + * the legacy token config when the issue has no `status_id` yet or the server + * predates the catalog. + */ +export function statusTheme( + statusDetail: StatusDetail | null | undefined, + fallbackStatus?: IssueStatus | string | null, +): StatusTheme { + if (statusDetail?.color) return statusThemeForColor(statusDetail.color); + const legacy = fallbackStatus ? STATUS_CONFIG[fallbackStatus as IssueStatus] : undefined; + return legacy ?? FALLBACK_STATUS_THEME; +} diff --git a/packages/core/issues/queries.ts b/packages/core/issues/queries.ts index 9b41bdf09f4..02b54788d37 100644 --- a/packages/core/issues/queries.ts +++ b/packages/core/issues/queries.ts @@ -156,6 +156,7 @@ export type IssueFlatFilter = MyIssuesFilter & ListIssuesParams, | "q" | "statuses" + | "status_ids" | "priorities" | "assignee_filters" | "include_no_assignee" diff --git a/packages/core/issues/stores/view-store.ts b/packages/core/issues/stores/view-store.ts index 4f0d723590b..9ac7b708359 100644 --- a/packages/core/issues/stores/view-store.ts +++ b/packages/core/issues/stores/view-store.ts @@ -147,7 +147,12 @@ export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string } export interface IssueViewState { viewMode: ViewMode; grouping: IssueGrouping; - statusFilters: IssueStatus[]; + /** + * Selected statuses. Entries are catalog ids (MUL-4809); a selection persisted + * by an older build may still hold legacy status tokens, which the views + * resolve at query time rather than rewriting storage. + */ + statusFilters: string[]; priorityFilters: IssuePriority[]; assigneeFilters: ActorFilterValue[]; includeNoAssignee: boolean; @@ -201,7 +206,7 @@ export interface IssueViewState { setGanttZoom: (zoom: GanttZoom) => void; toggleGanttShowCompleted: () => void; setGrouping: (grouping: IssueGrouping) => void; - toggleStatusFilter: (status: IssueStatus) => void; + toggleStatusFilter: (status: string) => void; togglePriorityFilter: (priority: IssuePriority) => void; toggleAssigneeFilter: (value: ActorFilterValue) => void; toggleNoAssignee: () => void; @@ -212,8 +217,15 @@ export interface IssueViewState { togglePropertyFilter: (propertyId: string, optionId: string) => void; setDateFilter: (filter: IssueDateFilter | null) => void; toggleAgentRunningFilter: () => void; - hideStatus: (status: IssueStatus) => void; - showStatus: (status: IssueStatus) => void; + /** + * Column show/hide is expressed through statusFilters, which now holds catalog + * ids as well as legacy tokens (MUL-4809) — so these take the same widened key. + * NOTE: hideStatus still seeds from ALL_STATUSES when no filter is active; that + * seed becomes wrong once the board renders one column per CATALOG status, and + * must then be passed the actual visible column keys. + */ + hideStatus: (status: string) => void; + showStatus: (status: string) => void; clearFilters: () => void; setSortBy: (field: SortField) => void; setSortDirection: (dir: SortDirection) => void; diff --git a/packages/core/package.json b/packages/core/package.json index 4d28e47227f..bb1247945a7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -78,6 +78,9 @@ "./properties": "./properties/index.ts", "./properties/queries": "./properties/queries.ts", "./properties/mutations": "./properties/mutations.ts", + "./issue-statuses": "./issue-statuses/index.ts", + "./issue-statuses/queries": "./issue-statuses/queries.ts", + "./issue-statuses/mutations": "./issue-statuses/mutations.ts", "./autopilots": "./autopilots/index.ts", "./autopilots/queries": "./autopilots/queries.ts", "./autopilots/mutations": "./autopilots/mutations.ts", diff --git a/packages/core/realtime/use-realtime-sync-ws-instance.test.tsx b/packages/core/realtime/use-realtime-sync-ws-instance.test.tsx index a282a9d2e5e..f07e1395320 100644 --- a/packages/core/realtime/use-realtime-sync-ws-instance.test.tsx +++ b/packages/core/realtime/use-realtime-sync-ws-instance.test.tsx @@ -108,10 +108,10 @@ describe("useRealtimeSync — ws instance change", () => { rerender({ ws: ws2 }); // Should have called invalidateQueries for all workspace-scoped keys - // (16 workspace-scoped [incl. property definitions] + 6 per-issue - // prefixes + 5 per-chat prefixes + 1 workspaceKeys.list() + 1 - // cross-workspace inbox unread summary = 29 calls) - expect(invalidateSpy).toHaveBeenCalledTimes(29); + // (17 workspace-scoped [incl. property definitions and the issue-status + // catalog] + 6 per-issue prefixes + 5 per-chat prefixes + 1 + // workspaceKeys.list() + 1 cross-workspace inbox unread summary = 30 calls) + expect(invalidateSpy).toHaveBeenCalledTimes(30); }); it("does not re-invalidate when rerendered with the same ws instance", () => { diff --git a/packages/core/realtime/use-realtime-sync.ts b/packages/core/realtime/use-realtime-sync.ts index 9c2fee09e4d..4ae935b70e5 100644 --- a/packages/core/realtime/use-realtime-sync.ts +++ b/packages/core/realtime/use-realtime-sync.ts @@ -16,6 +16,7 @@ import { autopilotKeys } from "../autopilots/queries"; import { runtimeKeys } from "../runtimes/queries"; import { labelKeys } from "../labels/queries"; import { propertyKeys } from "../properties/queries"; +import { issueStatusKeys } from "../issue-statuses/queries"; import { agentTaskSnapshotKeys, agentActivityKeys, @@ -499,6 +500,7 @@ function invalidateWorkspaceScopedQueries(qc: QueryClient): void { qc.invalidateQueries({ queryKey: chatKeys.all(wsId) }); qc.invalidateQueries({ queryKey: labelKeys.all(wsId) }); qc.invalidateQueries({ queryKey: propertyKeys.all(wsId) }); + qc.invalidateQueries({ queryKey: issueStatusKeys.all(wsId) }); } // Cross-workspace, so outside the wsId guard: a reconnect may have missed // inbox events from any workspace, so re-pull the switcher-dot summary. @@ -859,6 +861,20 @@ export function useRealtimeSync( }), ); + // Status-catalog changes (create / rename / recolor / default / archive). + // Issue caches embed status_detail, so a rename or recolor changes how + // existing rows render and an archive can move issues to another status — + // refetch both trees. + const unsubIssueStatusChanged = ["issue_status:created", "issue_status:updated"].map((event) => + ws.on(event as "issue_status:created" | "issue_status:updated", () => { + const wsId = getCurrentWsId(); + if (wsId) { + qc.invalidateQueries({ queryKey: issueStatusKeys.all(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.all(wsId) }); + } + }), + ); + const unsubInboxNew = ws.on("inbox:new", async (p) => { const { item } = p as InboxNewPayload; if (!item) return; @@ -1370,6 +1386,7 @@ export function useRealtimeSync( unsubIssueMetadataChanged(); unsubIssuePropertiesChanged(); unsubPropertyChanged.forEach((unsub) => unsub()); + unsubIssueStatusChanged.forEach((unsub) => unsub()); unsubInboxNew(); unsubCommentCreated(); unsubCommentUpdated(); diff --git a/packages/core/types/api.ts b/packages/core/types/api.ts index a69933a9481..ba255b31c6e 100644 --- a/packages/core/types/api.ts +++ b/packages/core/types/api.ts @@ -1,4 +1,4 @@ -import type { Issue, IssueMetadata, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue"; +import type { Issue, IssueMetadata, IssueStatus, IssuePriority, IssueAssigneeType, StatusCategory } from "./issue"; import type { MemberRole } from "./workspace"; import type { Project } from "./project"; @@ -7,6 +7,12 @@ export interface CreateIssueRequest { title: string; description?: string; status?: IssueStatus; + /** + * Targets a catalog row directly — the only way to create an issue straight + * into a CUSTOM status (MUL-4809 §3.1). Sending it together with `status` is + * accepted only when both resolve to the same status. + */ + status_id?: string; priority?: IssuePriority; assignee_type?: IssueAssigneeType; assignee_id?: string; @@ -26,6 +32,13 @@ export interface UpdateIssueRequest { title?: string; description?: string; status?: IssueStatus; + /** + * Targets a catalog row directly — the only way to reach a CUSTOM status, and + * unambiguous across renames (MUL-4809 §3.1). The API also accepts an alias or + * exact display name via `status`; sending both is accepted only when they + * resolve to the same status. + */ + status_id?: string; priority?: IssuePriority; assignee_type?: IssueAssigneeType | null; assignee_id?: string | null; @@ -83,6 +96,16 @@ export interface ListIssuesParams { /** Flat-table quick search. Matches issue title words or an exact issue number. */ q?: string; status?: IssueStatus; + /** Exact custom-status filter by catalog id (MUL-4809). */ + status_id?: string; + /** + * Multi-select custom-status facet by catalog id (MUL-4809). OR within the + * field. This is what the catalog-driven board columns and filter chips send; + * `statuses` remains the legacy-token equivalent for older clients. + */ + status_ids?: string[]; + /** Filter by one of the 5 status Categories (MUL-4809). */ + status_category?: StatusCategory; /** Multi-value table facet. OR within the field. */ statuses?: IssueStatus[]; priority?: IssuePriority; @@ -163,6 +186,16 @@ export interface ListGroupedIssuesParams { offset?: number; workspace_id?: string; statuses?: IssueStatus[]; + /** Exact custom-status filter by catalog id (MUL-4809). */ + status_id?: string; + /** + * Multi-select custom-status facet by catalog id (MUL-4809). OR within the + * field. This is what the catalog-driven board columns and filter chips send; + * `statuses` remains the legacy-token equivalent for older clients. + */ + status_ids?: string[]; + /** Filter by one of the 5 status Categories (MUL-4809). */ + status_category?: StatusCategory; priorities?: IssuePriority[]; assignee_types?: IssueAssigneeType[]; assignee_id?: string; diff --git a/packages/core/types/events.ts b/packages/core/types/events.ts index 059a4ae90b6..6cbe7ad8ced 100644 --- a/packages/core/types/events.ts +++ b/packages/core/types/events.ts @@ -1,5 +1,6 @@ import type { Issue, IssueMetadata, IssueReaction } from "./issue"; import type { IssueProperty, IssuePropertyValues } from "./property"; +import type { IssueStatusDefinition } from "./issue"; import type { Agent } from "./agent"; import type { InboxItem } from "./inbox"; import type { Comment, Reaction } from "./comment"; @@ -74,6 +75,8 @@ export type WSEventType = | "issue_properties:changed" | "property:created" | "property:updated" + | "issue_status:created" + | "issue_status:updated" | "pin:created" | "pin:deleted" | "pin:reordered" @@ -137,6 +140,18 @@ export interface PropertyChangedPayload { property: IssueProperty; } +/** + * Status-catalog change. `status` carries the full definition on create/update; + * an ARCHIVE arrives as an update carrying only `status_id` + `archived`, since + * statuses are never hard-deleted. Both shapes just invalidate the catalog, so + * every field is optional here. + */ +export interface IssueStatusChangedPayload { + status?: IssueStatusDefinition; + status_id?: string; + archived?: boolean; +} + export interface AgentStatusPayload { agent: Agent; } @@ -469,6 +484,8 @@ export interface WSEventPayloadMap { "issue_properties:changed": IssuePropertiesChangedPayload; "property:created": PropertyChangedPayload; "property:updated": PropertyChangedPayload; + "issue_status:created": IssueStatusChangedPayload; + "issue_status:updated": IssueStatusChangedPayload; "issue_reaction:added": IssueReactionAddedPayload; "issue_reaction:removed": IssueReactionRemovedPayload; "comment:created": CommentCreatedPayload; diff --git a/packages/core/types/index.ts b/packages/core/types/index.ts index 7516c0f714b..8ad149e0195 100644 --- a/packages/core/types/index.ts +++ b/packages/core/types/index.ts @@ -1,4 +1,15 @@ export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType, IssueMetadata, IssueMetadataValue, IssueReaction } from "./issue"; +export type { + StatusCategory, + StatusDetail, + StatusColor, + StatusIconKey, + IssueStatusDefinition, + IssueStatusCatalog, + CreateIssueStatusRequest, + UpdateIssueStatusRequest, +} from "./issue"; +export { STATUS_COLORS, STATUS_ICONS } from "./issue"; export type { Agent, AgentStatus, diff --git a/packages/core/types/issue.ts b/packages/core/types/issue.ts index e5b64c0fb24..d918dd0370b 100644 --- a/packages/core/types/issue.ts +++ b/packages/core/types/issue.ts @@ -10,6 +10,110 @@ export type IssueStatus = | "blocked" | "cancelled"; +// The 5 immutable status Categories — the only machine-readable status semantics +// in the custom-status model (MUL-4809). A custom or built-in status belongs to +// exactly one Category; automation branches on the Category, never the name. +export type StatusCategory = + | "backlog" + | "todo" + | "in_progress" + | "done" + | "cancelled"; + +// StatusDetail is the resolved catalog view of an issue's status, attached to +// issue responses by list/detail endpoints (MUL-4809). Optional/nullable: absent +// or null when the issue has no status_id yet — callers fall back to the legacy +// `status` token. name/icon/color are human-facing; category is the machine key. +export interface StatusDetail { + id: string; + name: string; + category: StatusCategory; + icon: string; + color: string; +} + +// The semantic color tokens a status may carry. Mirrors the server allowlist +// (validIssueStatusColors) so the settings picker can only offer values the API +// accepts, and so every surface can map a color to theme classes. +export const STATUS_COLORS = [ + "muted-foreground", + "warning", + "success", + "info", + "destructive", +] as const; +export type StatusColor = (typeof STATUS_COLORS)[number]; + +// The icon shapes a status may carry — the built-in status glyphs. Mirrors the +// server allowlist (validIssueStatusIcons). A custom status reuses whichever +// shape best fits its Category; icon is human-facing only. +export const STATUS_ICONS = [ + "backlog", + "todo", + "in_progress", + "in_review", + "blocked", + "done", + "cancelled", +] as const; +export type StatusIconKey = (typeof STATUS_ICONS)[number]; + +// IssueStatusDefinition is a catalog entry: the workspace-configurable +// definition behind a status (MUL-4809 §5). `category` and `system_key` are +// immutable after create; the 7 built-ins (is_system) can be renamed/recolored +// but never archived. +export interface IssueStatusDefinition { + id: string; + workspace_id: string; + name: string; + description: string; + icon: string; + color: string; + category: StatusCategory; + system_key: string | null; + is_system: boolean; + is_default: boolean; + position: number; + archived: boolean; + archived_at: string | null; + created_at: string; + updated_at: string; +} + +// IssueStatusCatalog is the workspace's status catalog plus the alias table. +// `category_defaults` maps each Category to its current default status id; +// `aliases` maps every alias token (5 Category + 2 legacy) to the status id it +// resolves to today, so a rename never leaves a caller guessing (§3.2). +export interface IssueStatusCatalog { + statuses: IssueStatusDefinition[]; + category_defaults: Record; + aliases: Record; + total: number; +} + +export interface CreateIssueStatusRequest { + name: string; + /** Immutable after create — pick a new status instead of moving Category. */ + category: StatusCategory; + description?: string; + icon: string; + color: string; + is_default?: boolean; +} + +/** + * Only the mutable fields. `category` / `system_key` are deliberately absent: + * the API rejects them with 400 immutable_field rather than ignoring them. + */ +export interface UpdateIssueStatusRequest { + name?: string; + description?: string; + icon?: string; + color?: string; + position?: number; + is_default?: boolean; +} + export type IssuePriority = "urgent" | "high" | "medium" | "low" | "none"; export type IssueAssigneeType = "member" | "agent" | "squad"; @@ -64,6 +168,12 @@ export interface Issue { properties: IssuePropertyValues; reactions?: IssueReaction[]; labels?: Label[]; + // Authoritative custom-status catalog id + resolved detail (MUL-4809), + // bulk-attached by list/detail endpoints like `labels`. Absent/null when the + // issue has no status_id yet or on paths that don't hydrate them; the legacy + // `status` field stays the fallback. + status_id?: string | null; + status_detail?: StatusDetail | null; created_at: string; updated_at: string; } diff --git a/packages/views/issues/components/batch-action-toolbar.tsx b/packages/views/issues/components/batch-action-toolbar.tsx index fd25259fabc..e582c0e20a3 100644 --- a/packages/views/issues/components/batch-action-toolbar.tsx +++ b/packages/views/issues/components/batch-action-toolbar.tsx @@ -109,7 +109,9 @@ export function BatchActionToolbar({ // handleBatchAssignee — that is the only batch action that should preview a // run fan-out. const handleBatchStatus = (updates: Partial) => { - if (!updates.status) return; + // The picker emits status_id for catalog statuses and the legacy token only + // when it falls back, so accept either (MUL-4809). + if (!updates.status && !updates.status_id) return; void handleBatchUpdate(updates); }; diff --git a/packages/views/issues/components/issue-detail.tsx b/packages/views/issues/components/issue-detail.tsx index cffa2b3412c..cd54b6e9c1d 100644 --- a/packages/views/issues/components/issue-detail.tsx +++ b/packages/views/issues/components/issue-detail.tsx @@ -635,11 +635,13 @@ function SubIssueRow({ child }: { child: Issue }) { } @@ -1557,7 +1559,12 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr {propertiesOpen &&
{/* Core props — always rendered. */} $.detail.prop_status)}> - + $.detail.prop_assignee)}> diff --git a/packages/views/issues/components/issues-header.tsx b/packages/views/issues/components/issues-header.tsx index 3b3f063e53f..3f5e4bb48f4 100644 --- a/packages/views/issues/components/issues-header.tsx +++ b/packages/views/issues/components/issues-header.tsx @@ -54,6 +54,8 @@ import { import { StatusIcon, PriorityIcon } from "."; import { useQuery } from "@tanstack/react-query"; import { useWorkspaceId } from "@multica/core/hooks"; +import { issueStatusListOptions } from "@multica/core/issue-statuses"; +import { localizableStatusKey } from "../utils/status-label"; import { memberListOptions, agentListOptions, squadListOptions } from "@multica/core/workspace/queries"; import { projectListOptions } from "@multica/core/projects/queries"; import { labelListOptions } from "@multica/core/labels/queries"; @@ -897,6 +899,21 @@ export function IssueDisplayControls({ facetCountsExact?: boolean; }) { const { t } = useT("issues"); + const displayWsId = useWorkspaceId(); + const { data: statusCatalog = [] } = useQuery(issueStatusListOptions(displayWsId)); + // Active statuses in Category order — the same ordering the picker uses, so a + // custom status sits next to the built-ins sharing its semantics. Empty means + // an old server or an unseeded workspace; the legacy token list is used then. + const statusOptions = useMemo(() => { + const order = ["backlog", "todo", "in_progress", "done", "cancelled"]; + return statusCatalog + .filter((s) => !s.archived) + .slice() + .sort((a, b) => { + const byCategory = order.indexOf(a.category) - order.indexOf(b.category); + return byCategory !== 0 ? byCategory : a.position - b.position; + }); + }, [statusCatalog]); const viewMode = useViewStore((s) => s.viewMode); const statusFilters = useViewStore((s) => s.statusFilters); const priorityFilters = useViewStore((s) => s.priorityFilters); @@ -1108,27 +1125,69 @@ export function IssueDisplayControls({ )} - {ALL_STATUSES.map((s) => { - const checked = statusFilters.includes(s); - const count = counts.status.get(s) ?? 0; - return ( - act.toggleStatusFilter(s)} - className={FILTER_ITEM_CLASS} - > - - - {t(($) => $.status[s])} - {count > 0 && ( - - {t(($) => $.filters.issue_count, { count })} - - )} - - ); - })} + {statusOptions.length > 0 + ? statusOptions.map((s) => { + // A selection persisted by an older build holds legacy + // tokens, so a row is checked by id OR by its system_key + // (MUL-4809). + const checked = + statusFilters.includes(s.id) || + (s.system_key != null && statusFilters.includes(s.system_key)); + // Counts are keyed by the legacy token, so several custom + // statuses in one Category share a count; show it only for + // the built-ins where it is exact. + const count = s.system_key ? (counts.status.get(s.system_key) ?? 0) : 0; + const localeKey = localizableStatusKey(s.system_key, s.name); + return ( + + act.toggleStatusFilter( + checked && s.system_key != null && statusFilters.includes(s.system_key) + ? s.system_key + : s.id, + ) + } + className={FILTER_ITEM_CLASS} + > + + + {localeKey ? t(($) => $.status[localeKey]) : s.name} + {count > 0 && ( + + {t(($) => $.filters.issue_count, { count })} + + )} + + ); + }) + : ALL_STATUSES.map((s) => { + const checked = statusFilters.includes(s); + const count = counts.status.get(s) ?? 0; + return ( + act.toggleStatusFilter(s)} + className={FILTER_ITEM_CLASS} + > + + + {t(($) => $.status[s])} + {count > 0 && ( + + {t(($) => $.filters.issue_count, { count })} + + )} + + ); + })} diff --git a/packages/views/issues/components/pickers/status-picker.tsx b/packages/views/issues/components/pickers/status-picker.tsx index 64657c01855..be72be5ef46 100644 --- a/packages/views/issues/components/pickers/status-picker.tsx +++ b/packages/views/issues/components/pickers/status-picker.tsx @@ -1,20 +1,30 @@ "use client"; -import { useState } from "react"; -import type { IssueStatus, UpdateIssueRequest } from "@multica/core/types"; -import { ALL_STATUSES, STATUS_CONFIG } from "@multica/core/issues/config"; +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { IssueStatus, StatusDetail, UpdateIssueRequest } from "@multica/core/types"; +import { ALL_STATUSES, STATUS_CONFIG, statusThemeForColor } from "@multica/core/issues/config"; +import { useWorkspaceId } from "@multica/core/hooks"; +import { issueStatusListOptions } from "@multica/core/issue-statuses"; import { StatusIcon } from "../status-icon"; import { PropertyPicker, PickerItem } from "./property-picker"; +import { localizableStatusKey } from "../../utils/status-label"; import { useT } from "../../../i18n"; +// Category presentation order, so custom statuses slot in next to the built-ins +// that share their machine semantics rather than being appended at the end. +const CATEGORY_ORDER = ["backlog", "todo", "in_progress", "done", "cancelled"] as const; + export function StatusPicker({ status, + statusDetail, onUpdate, trigger: customTrigger, triggerRender, open: controlledOpen, onOpenChange: controlledOnOpenChange, align, + mode = "id", }: { /** * The currently-selected status, used to check the matching row. `null` @@ -23,52 +33,115 @@ export function StatusPicker({ * status. */ status: IssueStatus | null; + /** + * The issue's resolved catalog entry, when it has one. Lets the picker check + * the right row when several statuses share a Category (and therefore share + * the legacy `status` token). + */ + statusDetail?: StatusDetail | null; onUpdate: (updates: Partial) => void; trigger?: React.ReactNode; triggerRender?: React.ReactElement; open?: boolean; onOpenChange?: (v: boolean) => void; align?: "start" | "center" | "end"; + /** + * "id" (default) emits `status_id`, which is the only way to reach a CUSTOM + * status. "legacy" emits the legacy `status` token and offers only the + * built-ins — for callers whose endpoint does not accept status_id yet (the + * create-issue dialog). Remove once the create path resolves status_id too. + */ + mode?: "id" | "legacy"; }) { const [internalOpen, setInternalOpen] = useState(false); const open = controlledOpen ?? internalOpen; const setOpen = controlledOnOpenChange ?? setInternalOpen; const { t } = useT("issues"); + const wsId = useWorkspaceId(); + + const { data: catalog = [] } = useQuery(issueStatusListOptions(wsId)); + + // Active statuses, Category-ordered then by position. An empty catalog means a + // server predating custom statuses (or a workspace not seeded yet) — fall back + // to the 7 legacy tokens so the picker always works. + const options = useMemo(() => { + if (mode === "legacy") return []; + const active = catalog.filter((s) => !s.archived); + if (active.length === 0) return []; + return [...active].sort((a, b) => { + const byCategory = + CATEGORY_ORDER.indexOf(a.category as (typeof CATEGORY_ORDER)[number]) - + CATEGORY_ORDER.indexOf(b.category as (typeof CATEGORY_ORDER)[number]); + return byCategory !== 0 ? byCategory : a.position - b.position; + }); + }, [catalog, mode]); + + const triggerContent = + customTrigger ?? + (status != null ? ( + <> + + {statusDetail?.name ?? t(($) => $.status[status])} + + ) : null); return ( - - {t(($) => $.status[status])} - - ) : null) - } + trigger={triggerContent} > - {ALL_STATUSES.map((s) => { - const c = STATUS_CONFIG[s]; - return ( - { - onUpdate({ status: s }); - setOpen(false); - }} - > - - {t(($) => $.status[s])} - - ); - })} + {options.length > 0 + ? options.map((s) => { + // Match on the catalog id when the issue has one; otherwise fall back + // to the legacy token so a not-yet-migrated issue still shows a check. + const selected = statusDetail + ? s.id === statusDetail.id + : status != null && (s.system_key === status || s.category === status); + return ( + { + onUpdate({ status_id: s.id }); + setOpen(false); + }} + > + + + {(() => { + const key = localizableStatusKey(s.system_key, s.name); + return key ? t(($) => $.status[key]) : s.name; + })()} + + + ); + }) + : ALL_STATUSES.map((s) => { + const c = STATUS_CONFIG[s]; + return ( + { + onUpdate({ status: s }); + setOpen(false); + }} + > + + {t(($) => $.status[s])} + + ); + })} ); } diff --git a/packages/views/issues/components/status-icon.tsx b/packages/views/issues/components/status-icon.tsx index 81d6b8032cc..bb45c1db744 100644 --- a/packages/views/issues/components/status-icon.tsx +++ b/packages/views/issues/components/status-icon.tsx @@ -1,5 +1,5 @@ -import type { IssueStatus } from "@multica/core/types"; -import { STATUS_CONFIG } from "@multica/core/issues/config"; +import type { IssueStatus, StatusDetail } from "@multica/core/types"; +import { STATUS_CONFIG, statusThemeForColor } from "@multica/core/issues/config"; // --------------------------------------------------------------------------- // Geometry constants (viewBox 0 0 14 14, center 7,7) @@ -157,24 +157,48 @@ const STATUS_RENDERERS: Record React.ReactNode> = { // Public component // --------------------------------------------------------------------------- +/** + * Renders a status glyph. + * + * Legacy call sites pass only `status` (one of the 7 built-in tokens) and get the + * shape + color baked into STATUS_CONFIG. Catalog-aware call sites additionally + * pass `detail` (the issue's resolved `status_detail`) or an explicit + * `icon`/`color` pair, which is how a CUSTOM status renders with its configured + * shape and color (MUL-4809). Anything unknown degrades to the neutral Todo + * glyph rather than rendering nothing. + */ export function StatusIcon({ status, className = "h-4 w-4", inheritColor = false, + detail, + icon, + color, }: { status: IssueStatus | string; className?: string; inheritColor?: boolean; + detail?: StatusDetail | null; + icon?: string | null; + color?: string | null; }) { + const shapeKey = icon ?? detail?.icon ?? status; + const knownShape = shapeKey && shapeKey in STATUS_RENDERERS ? (shapeKey as IssueStatus) : null; + const Renderer = knownShape ? STATUS_RENDERERS[knownShape] : TodoIcon; + + const colorToken = color ?? detail?.color ?? null; const knownStatus = status in STATUS_RENDERERS ? (status as IssueStatus) : null; - const cfg = knownStatus ? STATUS_CONFIG[knownStatus] : null; - const Renderer = knownStatus ? STATUS_RENDERERS[knownStatus] : TodoIcon; + const iconColor = colorToken + ? statusThemeForColor(colorToken).iconColor + : knownStatus + ? STATUS_CONFIG[knownStatus].iconColor + : "text-muted-foreground"; return ( diff --git a/packages/views/issues/components/table-view.tsx b/packages/views/issues/components/table-view.tsx index f14f42844e1..2a008b325dd 100644 --- a/packages/views/issues/components/table-view.tsx +++ b/packages/views/issues/components/table-view.tsx @@ -937,6 +937,7 @@ function IssueTableBodyCell({
resolveStatusFilterIds(statusFilters, statusCatalog), + [statusFilters, statusCatalog], + ); + const baseTableFacets = useMemo( () => ({ ...(debouncedTableSearch ? { q: debouncedTableSearch } : {}), - ...(statusFilters.length > 0 ? { statuses: statusFilters } : {}), + ...(statusFilterIds.length > 0 + ? { status_ids: statusFilterIds } + : statusFilters.length > 0 + ? { statuses: statusFilters as IssueStatus[] } + : {}), ...(priorityFilters.length > 0 ? { priorities: priorityFilters } : {}), ...(assigneeFilters.length > 0 ? { assignee_filters: assigneeFilters } @@ -384,6 +400,8 @@ export function useIssueSurfaceController({ workingFacets, activity, statusFilters, + statusFilterIds, + statusCatalog, priorityFilters, assigneeFilters, includeNoAssignee, diff --git a/packages/views/issues/surface/use-issue-surface-data.ts b/packages/views/issues/surface/use-issue-surface-data.ts index 44073703471..a86e6f682b5 100644 --- a/packages/views/issues/surface/use-issue-surface-data.ts +++ b/packages/views/issues/surface/use-issue-surface-data.ts @@ -23,7 +23,7 @@ import { issueSurfaceListOptions, } from "@multica/core/issues/surface/repository"; import type { IssueSurfaceQueryPlan } from "@multica/core/issues/surface/query-plan"; -import type { IssueStatus } from "@multica/core/types"; +import type { IssueStatus, IssueStatusDefinition } from "@multica/core/types"; import { applyIssueFilters, filterAssigneeGroups, @@ -133,6 +133,8 @@ export function useIssueSurfaceData({ workingFacets, activity, statusFilters, + statusFilterIds, + statusCatalog, priorityFilters, assigneeFilters, includeNoAssignee, @@ -163,7 +165,13 @@ export function useIssueSurfaceData({ /** Owned by the controller so the agents-working facet and the client * display filters read the same task snapshot. */ activity: IssueSurfaceActivity; - statusFilters: IssueStatus[]; + /** Selected statuses: catalog ids, or legacy tokens from an older persisted + * selection (MUL-4809). */ + statusFilters: string[]; + /** The same selection resolved to catalog ids, and the catalog itself, so the + * server facet and the client predicate agree. */ + statusFilterIds: string[]; + statusCatalog: IssueStatusDefinition[]; priorityFilters: IssueFilterState["priorityFilters"]; assigneeFilters: IssueFilterState["assigneeFilters"]; includeNoAssignee: boolean; @@ -184,7 +192,15 @@ export function useIssueSurfaceData({ const assigneeGroupFilter = useMemo( () => ({ ...queryPlan.groupedScopeFilter, - statuses: statusFilters.length > 0 ? statusFilters : [...ALL_STATUSES], + // Prefer the catalog facet so a selection can name a custom status; fall + // back to legacy tokens when the catalog is unavailable (MUL-4809). + ...(statusFilterIds.length > 0 + ? { status_ids: statusFilterIds } + : { + statuses: (statusFilters.length > 0 + ? statusFilters + : [...ALL_STATUSES]) as IssueStatus[], + }), priorities: priorityFilters, assignee_filters: assigneeFilters, include_no_assignee: includeNoAssignee, @@ -203,6 +219,7 @@ export function useIssueSurfaceData({ projectFilters, queryPlan.groupedScopeFilter, statusFilters, + statusFilterIds, ], ); @@ -349,6 +366,7 @@ export function useIssueSurfaceData({ showSubIssues, }), [ + statusCatalog, agentRunningFilter, assigneeFilters, creatorFilters, @@ -591,6 +609,10 @@ export function useIssueSurfaceData({ const activeFilters = useMemo( () => ({ + // The catalog travels with the display filters so the client predicate can + // match a selection of catalog ids against each issue's status_id, the + // same way the server facet does (MUL-4809). + statusCatalog, priorityFilters, assigneeFilters, includeNoAssignee, diff --git a/packages/views/issues/utils/filter.ts b/packages/views/issues/utils/filter.ts index 1a43b535704..31dd7b404d3 100644 --- a/packages/views/issues/utils/filter.ts +++ b/packages/views/issues/utils/filter.ts @@ -1,9 +1,14 @@ -import type { Issue, IssueStatus, IssuePriority, IssueAssigneeGroup } from "@multica/core/types"; +import { issueMatchesStatusFilter } from "./status-filter"; +import type { Issue, IssuePriority, IssueAssigneeGroup, IssueStatusDefinition } from "@multica/core/types"; import type { ActorFilterValue } from "@multica/core/issues/stores/view-store"; import type { IssueActivityState } from "../surface/activity"; export interface IssueFilters { - statusFilters: IssueStatus[]; + statusFilters: string[]; + /** Workspace status catalog, so a selection of catalog ids can be matched + * against each issue's status_id (MUL-4809). Optional: an unloaded catalog + * falls back to legacy-token matching. */ + statusCatalog?: IssueStatusDefinition[]; priorityFilters: IssuePriority[]; assigneeFilters: ActorFilterValue[]; includeNoAssignee: boolean; @@ -28,7 +33,11 @@ export interface IssueFilters { } export interface IssueFilterState { - statusFilters: IssueStatus[]; + statusFilters: string[]; + /** Workspace status catalog, so a selection of catalog ids can be matched + * against each issue's status_id (MUL-4809). Optional: an unloaded catalog + * falls back to legacy-token matching. */ + statusCatalog?: IssueStatusDefinition[]; priorityFilters: IssuePriority[]; assigneeFilters: ActorFilterValue[]; includeNoAssignee: boolean; @@ -96,7 +105,7 @@ export function applyIssueFilters( filters: IssueFilterState, context: IssueFilterContext = {}, ): Issue[] { - const { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters, workingOnly } = filters; + const { statusFilters, statusCatalog, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters, workingOnly } = filters; const hasAssigneeFilter = assigneeFilters.length > 0 || includeNoAssignee; const hasProjectFilter = projectFilters.length > 0 || includeNoProject; // Empty set passed without `agentRunningFilter` is a no-op. When the @@ -111,7 +120,7 @@ export function applyIssueFilters( if (hideSubIssues && issue.parent_issue_id) return false; - if (statusFilters.length > 0 && !statusFilters.includes(issue.status)) + if (!issueMatchesStatusFilter(issue, statusFilters, statusCatalog ?? [])) return false; if (priorityFilters.length > 0 && !priorityFilters.includes(issue.priority)) @@ -171,6 +180,7 @@ export function filterIssues(issues: Issue[], filters: IssueFilters): Issue[] { issues, { statusFilters: filters.statusFilters, + statusCatalog: filters.statusCatalog, priorityFilters: filters.priorityFilters, assigneeFilters: filters.assigneeFilters, includeNoAssignee: filters.includeNoAssignee, diff --git a/packages/views/issues/utils/status-filter.test.ts b/packages/views/issues/utils/status-filter.test.ts new file mode 100644 index 00000000000..47ac0e7192b --- /dev/null +++ b/packages/views/issues/utils/status-filter.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import type { Issue, IssueStatusDefinition } from "@multica/core/types"; +import { issueMatchesStatusFilter, resolveStatusFilterIds } from "./status-filter"; + +// MUL-4809 — status filter selections are persisted, and older builds stored the +// 7 legacy tokens. Catalog-driven filtering selects by catalog id, so a stored +// selection can hold both shapes and must keep working without rewriting storage. + +function status(over: Partial): IssueStatusDefinition { + return { + id: "id", + workspace_id: "ws", + name: "Name", + description: "", + icon: "todo", + color: "muted-foreground", + category: "todo", + system_key: null, + is_system: false, + is_default: false, + position: 0, + archived: false, + archived_at: null, + created_at: "", + updated_at: "", + ...over, + }; +} + +const TODO = status({ id: "todo-id", name: "Todo", system_key: "todo", is_system: true }); +const REVIEW = status({ + id: "review-id", + name: "In Review", + system_key: "in_review", + is_system: true, + category: "in_progress", +}); +const CUSTOM = status({ id: "qa-id", name: "Needs QA", category: "in_progress" }); +const CATALOG = [TODO, REVIEW, CUSTOM]; + +describe("resolveStatusFilterIds", () => { + it("keeps catalog ids as-is", () => { + expect(resolveStatusFilterIds([CUSTOM.id], CATALOG)).toEqual([CUSTOM.id]); + }); + + it("maps a legacy token to the built-in carrying that system_key", () => { + expect(resolveStatusFilterIds(["in_review"], CATALOG)).toEqual([REVIEW.id]); + }); + + it("does NOT fold custom statuses into a legacy Category token", () => { + // "todo" was a lane the user picked; it must not silently widen to every + // status that happens to share the Category. + expect(resolveStatusFilterIds(["todo"], CATALOG)).toEqual([TODO.id]); + }); + + it("handles a mixed selection and de-duplicates", () => { + const got = resolveStatusFilterIds(["todo", TODO.id, CUSTOM.id], CATALOG); + expect(got.sort()).toEqual([CUSTOM.id, TODO.id].sort()); + }); + + it("drops entries that resolve to nothing instead of erroring", () => { + expect(resolveStatusFilterIds(["no-such-token", "missing-id"], CATALOG)).toEqual([]); + }); + + it("returns nothing when the catalog has not loaded", () => { + expect(resolveStatusFilterIds(["todo"], [])).toEqual([]); + }); +}); + +describe("issueMatchesStatusFilter", () => { + it("passes everything when no filter is set", () => { + expect(issueMatchesStatusFilter({ status: "todo", status_id: TODO.id }, [], CATALOG)).toBe(true); + }); + + it("matches an issue by its status_id", () => { + const issue: Pick = { + status: "in_progress", + status_id: CUSTOM.id, + }; + expect(issueMatchesStatusFilter(issue, [CUSTOM.id], CATALOG)).toBe(true); + expect(issueMatchesStatusFilter(issue, [REVIEW.id], CATALOG)).toBe(false); + }); + + it("resolves a legacy selection against the issue's status_id", () => { + const issue: Pick = { + status: "in_review", + status_id: REVIEW.id, + }; + expect(issueMatchesStatusFilter(issue, ["in_review"], CATALOG)).toBe(true); + // The custom status shares the Category but is a different lane. + const custom: Pick = { + status: "in_progress", + status_id: CUSTOM.id, + }; + expect(issueMatchesStatusFilter(custom, ["in_review"], CATALOG)).toBe(false); + }); + + it("falls back to the legacy token when the issue has no status_id", () => { + // Unseeded workspace / server predating the catalog. + expect(issueMatchesStatusFilter({ status: "todo", status_id: null }, ["todo"], CATALOG)).toBe(true); + expect(issueMatchesStatusFilter({ status: "done", status_id: null }, ["todo"], CATALOG)).toBe(false); + }); + + it("does not hide everything while the catalog is still loading", () => { + // Catalog empty => ids resolve to nothing; the legacy comparison still runs. + expect(issueMatchesStatusFilter({ status: "todo", status_id: TODO.id }, ["todo"], [])).toBe(true); + }); +}); diff --git a/packages/views/issues/utils/status-filter.ts b/packages/views/issues/utils/status-filter.ts new file mode 100644 index 00000000000..e79ea9586ad --- /dev/null +++ b/packages/views/issues/utils/status-filter.ts @@ -0,0 +1,63 @@ +import type { Issue, IssueStatusDefinition } from "@multica/core/types"; + +/** + * Status filter selections are persisted (localStorage, via the view store), and + * they used to hold the 7 legacy status tokens. Catalog-driven filtering selects + * by catalog id instead, so a stored selection can contain BOTH shapes: + * + * - a catalog id, written by this build; + * - a legacy token ("todo", "in_review", …), left by an older build. + * + * Rather than rewriting storage — which would silently reinterpret a user's + * saved view — both are accepted and resolved at query time (MUL-4809). A legacy + * token maps to the BUILT-IN status carrying that system_key, i.e. exactly the + * lane the user had selected before; custom statuses are not folded in, because + * the user never chose them. + */ + +/** True when the entry is a catalog id rather than a legacy status token. */ +function isCatalogId(entry: string, catalog: IssueStatusDefinition[]): boolean { + return catalog.some((s) => s.id === entry); +} + +/** + * Resolve a stored selection into concrete catalog ids. Entries that resolve to + * nothing (a token whose built-in was archived, or an id from another workspace) + * are dropped, so a stale selection narrows rather than erroring. + */ +export function resolveStatusFilterIds( + selection: string[], + catalog: IssueStatusDefinition[], +): string[] { + if (selection.length === 0 || catalog.length === 0) return []; + const ids = new Set(); + for (const entry of selection) { + if (isCatalogId(entry, catalog)) { + ids.add(entry); + continue; + } + const builtIn = catalog.find((s) => s.system_key === entry && !s.archived); + if (builtIn) ids.add(builtIn.id); + } + return [...ids]; +} + +/** + * Client-side predicate mirroring the server's `status_ids` facet. Falls back to + * the legacy token comparison for issues that have no `status_id` yet (an + * unseeded workspace, or a server predating the catalog). + */ +export function issueMatchesStatusFilter( + issue: Pick, + selection: string[], + catalog: IssueStatusDefinition[], +): boolean { + if (selection.length === 0) return true; + if (issue.status_id) { + const ids = resolveStatusFilterIds(selection, catalog); + // An unloaded catalog resolves to nothing; don't hide everything while it + // is in flight — fall through to the legacy comparison below. + if (ids.length > 0) return ids.includes(issue.status_id); + } + return selection.includes(issue.status); +} diff --git a/packages/views/issues/utils/status-label.ts b/packages/views/issues/utils/status-label.ts new file mode 100644 index 00000000000..647c7996527 --- /dev/null +++ b/packages/views/issues/utils/status-label.ts @@ -0,0 +1,34 @@ +import type { IssueStatus } from "@multica/core/types"; + +/** + * Display names the 7 built-in statuses are seeded with (MUL-4809). A built-in + * still carrying its seeded name should render with the LOCALIZED label, so + * Chinese / Japanese / Korean users keep seeing translated statuses. Once an + * admin renames a built-in, that rename is the source of truth and wins over the + * translation — otherwise the settings page and the pickers would disagree. + * + * Custom statuses have no locale bundle by construction (their names are free + * text), so they always render verbatim. + */ +const SEEDED_BUILTIN_NAMES: Record = { + backlog: "Backlog", + todo: "Todo", + in_progress: "In Progress", + in_review: "In Review", + blocked: "Blocked", + done: "Done", + cancelled: "Cancelled", +}; + +/** + * The locale key to render a catalog status with, or null when the status's own + * name should be shown verbatim. Callers own the `t` call so this stays free of + * the i18n types. + */ +export function localizableStatusKey( + systemKey: string | null | undefined, + name: string, +): IssueStatus | null { + if (!systemKey) return null; + return SEEDED_BUILTIN_NAMES[systemKey] === name ? (systemKey as IssueStatus) : null; +} diff --git a/packages/views/locales/en/settings.json b/packages/views/locales/en/settings.json index 2447dce9330..528e3f5c486 100644 --- a/packages/views/locales/en/settings.json +++ b/packages/views/locales/en/settings.json @@ -134,9 +134,70 @@ "labs": "Labs", "members": "Members", "labels": "Labels", - "properties": "Properties" + "properties": "Properties", + "statuses": "Statuses" } }, + "statuses": { + "title": "Issue statuses", + "description": "Customize the statuses in this workspace. Every status belongs to one of five categories — automation reads only the category, while the name, color and icon are for people.", + "loading": "Loading statuses…", + "add": "Add status", + "empty_category": "No statuses in this category yet.", + "categories": { + "backlog": { + "label": "Backlog", + "hint": "Not scheduled. Assigned agents are not started automatically." + }, + "todo": { + "label": "Todo", + "hint": "Queued for work. Assigned agents start from here." + }, + "in_progress": { + "label": "In Progress", + "hint": "Being worked on — review and blocked stages live here too." + }, + "done": { + "label": "Done", + "hint": "Completed." + }, + "cancelled": { + "label": "Cancelled", + "hint": "Dropped without completing." + } + }, + "actions_open": "Actions for {name}", + "badge_default": "Default", + "badge_system": "Built-in", + "edit": "Edit", + "make_default": "Make default for this category", + "default_hint": "Each category has exactly one default. Promoting this one replaces the current default.", + "archive": "Archive", + "create_title": "New status", + "edit_title": "Edit status", + "category_locked": "Category: {category} — fixed after creation. To change it, create a new status and move the issues over.", + "field_name": "Name", + "name_placeholder": "e.g. Needs QA", + "field_description": "Description", + "description_placeholder": "What this status means (optional)", + "field_color": "Color", + "field_icon": "Icon", + "cancel": "Cancel", + "save": "Save", + "create": "Create", + "created": "Status created", + "create_failed": "Couldn't create the status", + "updated": "Status updated", + "update_failed": "Couldn't update the status", + "default_set": "{name} is now the default", + "archived": "Status archived", + "archive_failed": "Couldn't archive the status", + "archive_title": "Archive “{name}”?", + "archive_description": "It stops being available for new issues. Built-in statuses can't be archived.", + "migrate_to": "Move existing issues to", + "migrate_placeholder": "Pick a status", + "migrate_hint": "Required if any issue still uses this status. Only statuses in the same category are allowed." + }, "properties": { "title": "Properties", "description": "Define typed custom properties for issues in this workspace. Values are set on each issue and are readable and writable by agents.", diff --git a/packages/views/locales/ja/settings.json b/packages/views/locales/ja/settings.json index 41c23dee77e..81b29a1484e 100644 --- a/packages/views/locales/ja/settings.json +++ b/packages/views/locales/ja/settings.json @@ -131,9 +131,70 @@ "labs": "ラボ", "members": "メンバー", "labels": "ラベル", - "properties": "プロパティ" + "properties": "プロパティ", + "statuses": "ステータス" } }, + "statuses": { + "title": "Issue ステータス", + "description": "このワークスペースのステータスをカスタマイズします。各ステータスは 5 つのカテゴリのいずれかに属し、自動化はカテゴリのみを参照します。名前・色・アイコンは人が読むためのものです。", + "loading": "ステータスを読み込み中…", + "add": "ステータスを追加", + "empty_category": "このカテゴリにはまだステータスがありません。", + "categories": { + "backlog": { + "label": "バックログ", + "hint": "未スケジュール。エージェントは自動起動しません。" + }, + "todo": { + "label": "未着手", + "hint": "実行待ち。担当エージェントはここから開始します。" + }, + "in_progress": { + "label": "進行中", + "hint": "作業中。レビュー中・ブロック中もここに含まれます。" + }, + "done": { + "label": "完了", + "hint": "完了しています。" + }, + "cancelled": { + "label": "キャンセル済み", + "hint": "完了せずに終了しました。" + } + }, + "actions_open": "{name} の操作", + "badge_default": "デフォルト", + "badge_system": "組み込み", + "edit": "編集", + "make_default": "このカテゴリのデフォルトにする", + "default_hint": "各カテゴリのデフォルトは 1 つだけです。設定すると現在のデフォルトが置き換わります。", + "archive": "アーカイブ", + "create_title": "新しいステータス", + "edit_title": "ステータスを編集", + "category_locked": "カテゴリ: {category} — 作成後は変更できません。変更するには新しいステータスを作成し、Issue を移動してください。", + "field_name": "名前", + "name_placeholder": "例: QA 待ち", + "field_description": "説明", + "description_placeholder": "このステータスの意味(任意)", + "field_color": "色", + "field_icon": "アイコン", + "cancel": "キャンセル", + "save": "保存", + "create": "作成", + "created": "ステータスを作成しました", + "create_failed": "ステータスを作成できませんでした", + "updated": "ステータスを更新しました", + "update_failed": "ステータスを更新できませんでした", + "default_set": "{name} をデフォルトにしました", + "archived": "ステータスをアーカイブしました", + "archive_failed": "ステータスをアーカイブできませんでした", + "archive_title": "「{name}」をアーカイブしますか?", + "archive_description": "新しい Issue で選択できなくなります。組み込みステータスはアーカイブできません。", + "migrate_to": "既存の Issue の移動先", + "migrate_placeholder": "ステータスを選択", + "migrate_hint": "このステータスを使用中の Issue がある場合は必須です。同じカテゴリのステータスのみ選択できます。" + }, "properties": { "title": "プロパティ", "description": "このワークスペースの Issue に型付きカスタムプロパティを定義します。値は各 Issue に設定され、エージェントが読み書きできます。", diff --git a/packages/views/locales/ko/settings.json b/packages/views/locales/ko/settings.json index 715f2877f09..d85cc1cf76c 100644 --- a/packages/views/locales/ko/settings.json +++ b/packages/views/locales/ko/settings.json @@ -131,9 +131,70 @@ "labs": "실험실", "members": "멤버", "labels": "레이블", - "properties": "속성" + "properties": "속성", + "statuses": "상태" } }, + "statuses": { + "title": "이슈 상태", + "description": "이 워크스페이스의 상태를 사용자 지정합니다. 각 상태는 다섯 개 카테고리 중 하나에 속하며, 자동화는 카테고리만 읽습니다. 이름·색상·아이콘은 사람을 위한 것입니다.", + "loading": "상태를 불러오는 중…", + "add": "상태 추가", + "empty_category": "이 카테고리에는 아직 상태가 없습니다.", + "categories": { + "backlog": { + "label": "백로그", + "hint": "일정 없음. 지정된 에이전트가 자동으로 시작되지 않습니다." + }, + "todo": { + "label": "할 일", + "hint": "작업 대기. 지정된 에이전트가 여기서 시작합니다." + }, + "in_progress": { + "label": "진행 중", + "hint": "작업 중 — 리뷰 중과 막힘도 여기에 포함됩니다." + }, + "done": { + "label": "완료", + "hint": "완료되었습니다." + }, + "cancelled": { + "label": "취소됨", + "hint": "완료하지 않고 중단했습니다." + } + }, + "actions_open": "{name} 작업", + "badge_default": "기본", + "badge_system": "기본 제공", + "edit": "편집", + "make_default": "이 카테고리의 기본값으로 설정", + "default_hint": "카테고리마다 기본값은 하나뿐입니다. 설정하면 현재 기본값이 대체됩니다.", + "archive": "보관", + "create_title": "새 상태", + "edit_title": "상태 편집", + "category_locked": "카테고리: {category} — 생성 후에는 변경할 수 없습니다. 변경하려면 새 상태를 만들고 이슈를 옮기세요.", + "field_name": "이름", + "name_placeholder": "예: QA 필요", + "field_description": "설명", + "description_placeholder": "이 상태의 의미 (선택)", + "field_color": "색상", + "field_icon": "아이콘", + "cancel": "취소", + "save": "저장", + "create": "생성", + "created": "상태를 만들었습니다", + "create_failed": "상태를 만들지 못했습니다", + "updated": "상태를 업데이트했습니다", + "update_failed": "상태를 업데이트하지 못했습니다", + "default_set": "{name}이(가) 기본값이 되었습니다", + "archived": "상태를 보관했습니다", + "archive_failed": "상태를 보관하지 못했습니다", + "archive_title": "“{name}”을(를) 보관할까요?", + "archive_description": "새 이슈에서 선택할 수 없게 됩니다. 기본 제공 상태는 보관할 수 없습니다.", + "migrate_to": "기존 이슈를 옮길 상태", + "migrate_placeholder": "상태 선택", + "migrate_hint": "이 상태를 사용하는 이슈가 있으면 필수입니다. 같은 카테고리의 상태만 선택할 수 있습니다." + }, "properties": { "title": "속성", "description": "이 워크스페이스의 Issue에 타입이 있는 사용자 지정 속성을 정의합니다. 값은 각 Issue에 설정되며 에이전트가 읽고 쓸 수 있습니다.", diff --git a/packages/views/locales/zh-Hans/settings.json b/packages/views/locales/zh-Hans/settings.json index e80a7cc1b8d..ddf0ac26bf1 100644 --- a/packages/views/locales/zh-Hans/settings.json +++ b/packages/views/locales/zh-Hans/settings.json @@ -134,9 +134,70 @@ "labs": "实验室", "members": "成员", "labels": "标签", - "properties": "属性" + "properties": "属性", + "statuses": "状态" } }, + "statuses": { + "title": "Issue 状态", + "description": "自定义此工作区的状态。每个状态都属于五个分类之一——自动化只读取分类,名称、颜色和图标仅面向人。", + "loading": "正在加载状态…", + "add": "新增状态", + "empty_category": "此分类下还没有状态。", + "categories": { + "backlog": { + "label": "待规划", + "hint": "尚未排期,不会自动启动 Agent。" + }, + "todo": { + "label": "待办", + "hint": "进入待执行范围,指派的 Agent 从这里开始。" + }, + "in_progress": { + "label": "进行中", + "hint": "正在处理,审核中与已阻塞也属于这一类。" + }, + "done": { + "label": "已完成", + "hint": "已经完成。" + }, + "cancelled": { + "label": "已取消", + "hint": "未完成即终止。" + } + }, + "actions_open": "{name} 的操作", + "badge_default": "默认", + "badge_system": "内置", + "edit": "编辑", + "make_default": "设为该分类的默认状态", + "default_hint": "每个分类有且只有一个默认状态,设为默认会替换当前的默认状态。", + "archive": "归档", + "create_title": "新建状态", + "edit_title": "编辑状态", + "category_locked": "分类:{category} —— 创建后不可修改。如需更换,请新建状态并把 Issue 迁移过去。", + "field_name": "名称", + "name_placeholder": "例如:待验收", + "field_description": "描述", + "description_placeholder": "这个状态代表什么(可选)", + "field_color": "颜色", + "field_icon": "图标", + "cancel": "取消", + "save": "保存", + "create": "创建", + "created": "状态已创建", + "create_failed": "创建状态失败", + "updated": "状态已更新", + "update_failed": "更新状态失败", + "default_set": "{name} 已设为默认", + "archived": "状态已归档", + "archive_failed": "归档状态失败", + "archive_title": "归档「{name}」?", + "archive_description": "归档后新的 Issue 将无法再选择该状态。内置状态不能归档。", + "migrate_to": "将现有 Issue 迁移到", + "migrate_placeholder": "选择一个状态", + "migrate_hint": "如果仍有 Issue 使用该状态,则必须选择;只能选择同一分类下的状态。" + }, "properties": { "title": "属性", "description": "为该工作区的 Issue 定义类型化的自定义属性。属性值设置在每个 Issue 上,agent 可读写。", diff --git a/packages/views/modals/create-issue.tsx b/packages/views/modals/create-issue.tsx index 208bf4789ec..aea1eb0fe15 100644 --- a/packages/views/modals/create-issue.tsx +++ b/packages/views/modals/create-issue.tsx @@ -3,6 +3,7 @@ import { useState, useRef, useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; import { useNavigation } from "../navigation"; +import { issueStatusListOptions } from "@multica/core/issue-statuses"; import { AlertTriangle, ArrowDown, @@ -241,6 +242,10 @@ export function ManualCreatePanel({ onDrop: (files) => files.forEach((f) => descEditorRef.current?.uploadFile(f)), }); const [status, setStatus] = useState((data?.status as IssueStatus) || draft.status); + // The catalog row the user picked, when they picked one. Deliberately not part + // of the persisted draft: a resumed draft falls back to its legacy token, which + // the server still resolves (MUL-4809). + const [statusId, setStatusId] = useState(undefined); const [priority, setPriority] = useState( (data?.priority as IssuePriority | undefined) ?? draft.priority, ); @@ -303,6 +308,7 @@ export function ManualCreatePanel({ // List cache usually has it already, so this resolves synchronously. const wsId = useWorkspaceId(); const { data: workspaceProperties = [] } = useQuery(propertyListOptions(wsId)); + const { data: statusCatalog = [] } = useQuery(issueStatusListOptions(wsId)); const { data: parentIssue } = useQuery({ ...issueDetailOptions(wsId, parentIssueId ?? ""), enabled: !!parentIssueId, @@ -448,7 +454,9 @@ export function ManualCreatePanel({ const issue = await createIssueMutation.mutateAsync({ title: title.trim(), description, - status, + // status_id wins when the user picked a catalog row; `status` stays as the + // compat projection the server also accepts. + ...(statusId ? { status_id: statusId } : { status }), priority, assignee_type: assigneeType, assignee_id: assigneeId, @@ -841,7 +849,29 @@ export function ManualCreatePanel({ {showField.status && ( { if (u.status) updateStatus(u.status); }} + statusDetail={ + statusCatalog.find((s) => s.id === statusId) + ? { + id: statusId as string, + name: statusCatalog.find((s) => s.id === statusId)!.name, + category: statusCatalog.find((s) => s.id === statusId)!.category, + icon: statusCatalog.find((s) => s.id === statusId)!.icon, + color: statusCatalog.find((s) => s.id === statusId)!.color, + } + : null + } + onUpdate={(u) => { + if (u.status_id) { + const row = statusCatalog.find((s) => s.id === u.status_id); + setStatusId(u.status_id); + // Keep the legacy token in sync for the draft + the + // backlog-specific create behaviour that keys off it. + if (row) updateStatus((row.system_key ?? row.category) as IssueStatus); + } else if (u.status) { + setStatusId(undefined); + updateStatus(u.status); + } + }} triggerRender={} align="start" open={fieldPickerOpen === "status" ? true : undefined} diff --git a/packages/views/runtimes/components/provider-logo.tsx b/packages/views/runtimes/components/provider-logo.tsx index a157e2afd9f..26869484323 100644 --- a/packages/views/runtimes/components/provider-logo.tsx +++ b/packages/views/runtimes/components/provider-logo.tsx @@ -286,6 +286,9 @@ function GrokLogo({ className }: { className: string }) { // Qwen Code — official SVG copied verbatim from QwenLM/qwen-code's desktop // brand assets (packages/desktop/apps/electron/resources/brands/qwen-code/icon.svg). +// Normalise via unknown, exactly like antigravityLogoSrc above: vite/client +// types *.svg as a plain string, so narrowing on `typeof === "string"` leaves +// the else-branch as `never` and `.src` fails the desktop typecheck. const qwenLogoSrc: string = (() => { const asset = qwenLogo as unknown; return typeof asset === "string" ? asset : (asset as { src: string }).src; diff --git a/packages/views/settings/components/settings-page.tsx b/packages/views/settings/components/settings-page.tsx index 6a4b7990b1b..0c8e41feb96 100644 --- a/packages/views/settings/components/settings-page.tsx +++ b/packages/views/settings/components/settings-page.tsx @@ -15,6 +15,7 @@ import { Tags, Keyboard, ListTodo, + CircleDashed, } from "lucide-react"; import { GitHubMark } from "./github-mark"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@multica/ui/components/ui/tabs"; @@ -35,6 +36,7 @@ import { LabsTab } from "./labs-tab"; import { NotificationsTab } from "./notifications-tab"; import { LabelsTab } from "./labels-tab"; import { PropertiesTab } from "./properties-tab"; +import { StatusesTab } from "./statuses-tab"; import { KeyboardShortcutsTab } from "./keyboard-shortcuts-tab"; import { useT } from "../../i18n"; @@ -58,6 +60,7 @@ const WORKSPACE_TAB_KEYS = [ "members", "labels", "properties", + "statuses", ] as const; const WORKSPACE_TAB_VALUES = { general: "workspace", @@ -68,6 +71,7 @@ const WORKSPACE_TAB_VALUES = { members: "members", labels: "labels", properties: "properties", + statuses: "statuses", } as const; const WORKSPACE_TAB_ICONS = { general: Settings, @@ -78,6 +82,7 @@ const WORKSPACE_TAB_ICONS = { members: Users, labels: Tags, properties: SlidersHorizontal, + statuses: CircleDashed, } as const; const DEFAULT_TAB = "profile"; @@ -207,7 +212,7 @@ export function SettingsPage({ extraAccountTabs }: SettingsPageProps = {}) { {/* Right content */}
-
+
@@ -223,6 +228,7 @@ export function SettingsPage({ extraAccountTabs }: SettingsPageProps = {}) { + {extraAccountTabs?.map((tab) => ( {tab.content} ))} diff --git a/packages/views/settings/components/statuses-tab.tsx b/packages/views/settings/components/statuses-tab.tsx new file mode 100644 index 00000000000..1e7e81080e0 --- /dev/null +++ b/packages/views/settings/components/statuses-tab.tsx @@ -0,0 +1,565 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Archive, Check, MoreHorizontal, Pencil, Plus } from "lucide-react"; +import { toast } from "sonner"; +import { useWorkspaceId } from "@multica/core/hooks"; +import { + issueStatusCatalogOptions, + useArchiveIssueStatus, + useCreateIssueStatus, + useUpdateIssueStatus, +} from "@multica/core/issue-statuses"; +import { STATUS_COLORS, STATUS_ICONS } from "@multica/core/types"; +import type { IssueStatusDefinition, StatusCategory } from "@multica/core/types"; +import { statusThemeForColor } from "@multica/core/issues/config"; +import { Button } from "@multica/ui/components/ui/button"; +import { Input } from "@multica/ui/components/ui/input"; +import { Textarea } from "@multica/ui/components/ui/textarea"; +import { Label as FieldLabel } from "@multica/ui/components/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@multica/ui/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@multica/ui/components/ui/alert-dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@multica/ui/components/ui/dropdown-menu"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@multica/ui/components/ui/select"; +import { cn } from "@multica/ui/lib/utils"; +import { StatusIcon } from "../../issues/components/status-icon"; +import { useT } from "../../i18n"; +import { SettingsTab } from "./settings-layout"; + +/** + * Workspace status catalog management (MUL-4809 §7.1). + * + * Statuses are grouped by their Category, which is the ONLY machine semantics + * and is immutable after create — so Category is a create-time choice presented + * as a group, never an edit control. The 7 built-ins can be renamed/recolored + * but never archived, and each Category always keeps exactly one default. + */ + +// Fixed presentation order; matches the server's Category ordering. +const CATEGORY_ORDER: StatusCategory[] = [ + "backlog", + "todo", + "in_progress", + "done", + "cancelled", +]; + +interface StatusDraft { + name: string; + description: string; + category: StatusCategory; + icon: string; + color: string; + is_default: boolean; +} + +const EMPTY_DRAFT: StatusDraft = { + name: "", + description: "", + category: "todo", + icon: "todo", + color: "muted-foreground", + is_default: false, +}; + +function draftFromStatus(status: IssueStatusDefinition): StatusDraft { + return { + name: status.name, + description: status.description ?? "", + category: status.category, + icon: status.icon, + color: status.color, + is_default: status.is_default, + }; +} + +/** A color swatch row — the allowlist is small and fixed, so no color wheel. */ +function ColorChoices({ + value, + onChange, +}: { + value: string; + onChange: (color: string) => void; +}) { + return ( +
+ {STATUS_COLORS.map((color) => ( + + ))} +
+ ); +} + +/** Icon shape picker — the 7 built-in glyphs. */ +function IconChoices({ + value, + color, + onChange, +}: { + value: string; + color: string; + onChange: (icon: string) => void; +}) { + return ( +
+ {STATUS_ICONS.map((icon) => ( + + ))} +
+ ); +} + +export function StatusesTab() { + const { t } = useT("settings"); + const wsId = useWorkspaceId(); + + const { data: catalog, isLoading } = useQuery(issueStatusCatalogOptions(wsId)); + const statuses = useMemo(() => catalog?.statuses ?? [], [catalog]); + + const [createFor, setCreateFor] = useState(null); + const [editing, setEditing] = useState(null); + const [draft, setDraft] = useState(EMPTY_DRAFT); + const [pendingArchive, setPendingArchive] = useState(null); + const [migrateTo, setMigrateTo] = useState(""); + + const createStatus = useCreateIssueStatus(); + const updateStatus = useUpdateIssueStatus(); + const archiveStatus = useArchiveIssueStatus(); + + const byCategory = useMemo(() => { + const grouped = new Map(); + for (const category of CATEGORY_ORDER) grouped.set(category, []); + for (const status of statuses) { + if (status.archived) continue; + grouped.get(status.category)?.push(status); + } + for (const list of grouped.values()) list.sort((a, b) => a.position - b.position); + return grouped; + }, [statuses]); + + /** Same-Category, non-archived, excluding the one being archived. */ + const migrationTargets = useMemo(() => { + if (!pendingArchive) return []; + return statuses.filter( + (s) => !s.archived && s.category === pendingArchive.category && s.id !== pendingArchive.id, + ); + }, [statuses, pendingArchive]); + + const openCreate = (category: StatusCategory) => { + setDraft({ ...EMPTY_DRAFT, category, icon: category === "in_progress" ? "in_progress" : category }); + setCreateFor(category); + }; + + const openEdit = (status: IssueStatusDefinition) => { + setDraft(draftFromStatus(status)); + setEditing(status); + }; + + const closeDialogs = () => { + setCreateFor(null); + setEditing(null); + setDraft(EMPTY_DRAFT); + }; + + const handleCreate = async () => { + const name = draft.name.trim(); + if (!name || !createFor) return; + try { + await createStatus.mutateAsync({ + name, + category: createFor, + description: draft.description.trim(), + icon: draft.icon, + color: draft.color, + is_default: draft.is_default, + }); + toast.success(t(($) => $.statuses.created)); + closeDialogs(); + } catch (error) { + toast.error(error instanceof Error ? error.message : t(($) => $.statuses.create_failed)); + } + }; + + const handleUpdate = async () => { + const name = draft.name.trim(); + if (!name || !editing) return; + try { + await updateStatus.mutateAsync({ + id: editing.id, + name, + description: draft.description.trim(), + icon: draft.icon, + color: draft.color, + // Only ever promote: the server keeps exactly one default per Category, + // so un-setting has no meaning — you promote a different status instead. + ...(draft.is_default && !editing.is_default ? { is_default: true } : {}), + }); + toast.success(t(($) => $.statuses.updated)); + closeDialogs(); + } catch (error) { + toast.error(error instanceof Error ? error.message : t(($) => $.statuses.update_failed)); + } + }; + + const handleSetDefault = async (status: IssueStatusDefinition) => { + try { + await updateStatus.mutateAsync({ id: status.id, is_default: true }); + toast.success(t(($) => $.statuses.default_set, { name: status.name })); + } catch (error) { + toast.error(error instanceof Error ? error.message : t(($) => $.statuses.update_failed)); + } + }; + + const handleArchive = async () => { + if (!pendingArchive) return; + try { + await archiveStatus.mutateAsync({ + id: pendingArchive.id, + migrateToStatusId: migrateTo || undefined, + }); + toast.success(t(($) => $.statuses.archived)); + setPendingArchive(null); + setMigrateTo(""); + } catch (error) { + // The server 409s when the status still holds issues and no migration + // target was given; surface its message so the user knows to pick one. + toast.error(error instanceof Error ? error.message : t(($) => $.statuses.archive_failed)); + } + }; + + const dialogOpen = createFor !== null || editing !== null; + const isSaving = createStatus.isPending || updateStatus.isPending; + + return ( + $.statuses.title)} + description={t(($) => $.statuses.description)} + > + {isLoading ? ( +
+ {t(($) => $.statuses.loading)} +
+ ) : ( +
+ {CATEGORY_ORDER.map((category) => { + const rows = byCategory.get(category) ?? []; + return ( +
+
+
+

+ {t(($) => $.statuses.categories[category].label)} +

+

+ {t(($) => $.statuses.categories[category].hint)} +

+
+ +
+ +
+ {rows.length === 0 ? ( +

+ {t(($) => $.statuses.empty_category)} +

+ ) : ( +
+ {rows.map((status) => ( +
+ +
+
+ {status.name} + {status.is_default ? ( + + {t(($) => $.statuses.badge_default)} + + ) : null} + {status.is_system ? ( + + {t(($) => $.statuses.badge_system)} + + ) : null} +
+ {status.description ? ( +

+ {status.description} +

+ ) : null} +
+ + + $.statuses.actions_open, { name: status.name })} + > + + + } + /> + + openEdit(status)}> + + {t(($) => $.statuses.edit)} + + {!status.is_default ? ( + void handleSetDefault(status)}> + + {t(($) => $.statuses.make_default)} + + ) : null} + {/* System statuses are permanent (§5.4). */} + {!status.is_system ? ( + { + setMigrateTo(""); + setPendingArchive(status); + }} + > + + {t(($) => $.statuses.archive)} + + ) : null} + + +
+ ))} +
+ )} +
+
+ ); + })} +
+ )} + + {/* Create / edit */} + (open ? null : closeDialogs())}> + + + + {editing ? t(($) => $.statuses.edit_title) : t(($) => $.statuses.create_title)} + + + {t(($) => $.statuses.category_locked, { + category: t(($) => $.statuses.categories[(editing?.category ?? createFor ?? "todo")].label), + })} + + + +
+
+ {t(($) => $.statuses.field_name)} + setDraft((d) => ({ ...d, name: event.target.value }))} + placeholder={t(($) => $.statuses.name_placeholder)} + /> +
+ +
+ + {t(($) => $.statuses.field_description)} + +