From a818d8d5bb90be7430a33eabbe1f21e55947ee57 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Fri, 24 Jul 2026 06:56:57 -0300 Subject: [PATCH] feat(task-board): kanban feature parity (columns, automation, tags, comments, attachments, sprints, releases, activity) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/migrations/150-task-board-features.ts | 147 ++ .../api/migrations/151-task-board-item-seq.ts | 40 + .../api/migrations/152-task-board-activity.ts | 38 + apps/api/migrations/index.ts | 6 + apps/api/src/api/routes/org-scoped.ts | 2 + .../src/api/routes/task-board-attachments.ts | 65 + apps/api/src/storage/organization-settings.ts | 12 + apps/api/src/storage/ports.ts | 1 + apps/api/src/storage/task-board.ts | 558 +++++- apps/api/src/storage/types.ts | 219 +++ apps/api/src/tools/index.ts | 15 + .../src/tools/organization/settings-get.ts | 2 + .../src/tools/organization/settings-update.ts | 4 + apps/api/src/tools/task-board/activity.ts | 62 + apps/api/src/tools/task-board/attachments.ts | 117 ++ .../src/tools/task-board/column-automation.ts | 68 + apps/api/src/tools/task-board/comments.ts | 217 +++ apps/api/src/tools/task-board/create.ts | 32 +- .../tools/task-board/enqueue-super-agent.ts | 41 +- apps/api/src/tools/task-board/index.ts | 23 + apps/api/src/tools/task-board/releases.ts | 100 ++ .../api/src/tools/task-board/run-reactions.ts | 29 +- apps/api/src/tools/task-board/schema.ts | 91 + apps/api/src/tools/task-board/sprints.ts | 144 ++ apps/api/src/tools/task-board/update.ts | 89 +- .../src/hooks/use-organization-settings.ts | 29 + apps/web/src/hooks/use-task-board-activity.ts | 25 + apps/web/src/hooks/use-task-board-comments.ts | 114 ++ apps/web/src/hooks/use-task-board-items.ts | 14 +- apps/web/src/hooks/use-task-board-releases.ts | 53 + apps/web/src/i18n/en/settings.ts | 26 + apps/web/src/i18n/en/task-board.ts | 69 +- apps/web/src/i18n/pt-br/settings.ts | 26 + apps/web/src/i18n/pt-br/task-board.ts | 70 +- apps/web/src/index.tsx | 9 + apps/web/src/layouts/settings-layout.tsx | 8 + .../web/src/layouts/task-board/config.test.ts | 6 + apps/web/src/layouts/task-board/config.tsx | 170 +- apps/web/src/layouts/task-board/index.tsx | 553 +++++- .../src/layouts/task-board/task-dialog.tsx | 1501 ++++++++++++++++- .../src/layouts/task-board/task-filters.tsx | 77 +- apps/web/src/lib/query-keys.ts | 20 + apps/web/src/routes/orgs/settings/board.tsx | 10 + apps/web/src/views/settings/board.tsx | 417 +++++ .../e2e/tests/task-board-features.spec.ts | 141 ++ packages/shared/src/entities.ts | 3 + packages/shared/src/organization/schema.ts | 41 + .../shared/src/task-board-columns.test.ts | 112 ++ packages/shared/src/task-board-columns.ts | 102 ++ .../shared/src/tools/registry-metadata.ts | 94 ++ packages/shared/src/tools/tool-io.ts | 365 +++- packages/shared/src/utils/generate-id.ts | 7 +- 52 files changed, 6001 insertions(+), 183 deletions(-) create mode 100644 apps/api/migrations/150-task-board-features.ts create mode 100644 apps/api/migrations/151-task-board-item-seq.ts create mode 100644 apps/api/migrations/152-task-board-activity.ts create mode 100644 apps/api/src/api/routes/task-board-attachments.ts create mode 100644 apps/api/src/tools/task-board/activity.ts create mode 100644 apps/api/src/tools/task-board/attachments.ts create mode 100644 apps/api/src/tools/task-board/column-automation.ts create mode 100644 apps/api/src/tools/task-board/comments.ts create mode 100644 apps/api/src/tools/task-board/releases.ts create mode 100644 apps/api/src/tools/task-board/sprints.ts create mode 100644 apps/web/src/hooks/use-task-board-activity.ts create mode 100644 apps/web/src/hooks/use-task-board-comments.ts create mode 100644 apps/web/src/hooks/use-task-board-releases.ts create mode 100644 apps/web/src/routes/orgs/settings/board.tsx create mode 100644 apps/web/src/views/settings/board.tsx create mode 100644 packages/e2e/tests/task-board-features.spec.ts create mode 100644 packages/shared/src/task-board-columns.test.ts create mode 100644 packages/shared/src/task-board-columns.ts diff --git a/apps/api/migrations/150-task-board-features.ts b/apps/api/migrations/150-task-board-features.ts new file mode 100644 index 0000000000..58946e51b5 --- /dev/null +++ b/apps/api/migrations/150-task-board-features.ts @@ -0,0 +1,147 @@ +import { type Kysely, sql } from "kysely"; + +/** + * Task board feature expansion — everything a team needs to run work on the + * Studio board instead of an external tracker (Jira parity for the + * ai-services-panel use case): + * + * - `organization_settings.task_board` (jsonb) — per-org board config: + * custom columns (each mapped to a canonical stage + optional per-column + * agent automation), and the sprint/release feature toggles. Null = the + * default simple board (the 5 built-in columns, sprints/releases off). + * - `task_board_items` grows `column_id` (custom-column placement — null means + * "derive from status"), `tags` (free-form labels), `sprint_id`, + * `release_id`, and `automation_column_id` (the column whose automation last + * ran, the re-trigger guard). + * - `task_board_comments` — comment stream per task, one level of replies via + * `parent_id`. + * - `task_board_attachments` — files/images on a task or a comment; bytes live + * in the row (capped at the tool layer), served by an org-scoped route. + * - `task_board_sprints` / `task_board_releases` — the optional planning + * entities behind the settings toggles. + */ +export async function up(db: Kysely): Promise { + await db.schema + .alterTable("organization_settings") + .addColumn("task_board", "jsonb") + .execute(); + + await db.schema + .alterTable("task_board_items") + .addColumn("column_id", "text") + .addColumn("tags", "jsonb") + .addColumn("sprint_id", "text") + .addColumn("release_id", "text") + .addColumn("automation_column_id", "text") + .execute(); + + await db.schema + .createTable("task_board_comments") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("organization_id", "text", (col) => + col.notNull().references("organization.id").onDelete("cascade"), + ) + .addColumn("task_board_item_id", "text", (col) => + col.notNull().references("task_board_items.id").onDelete("cascade"), + ) + .addColumn("parent_id", "text", (col) => + col.references("task_board_comments.id").onDelete("cascade"), + ) + .addColumn("body", "text", (col) => col.notNull()) + .addColumn("created_by", "text", (col) => col.notNull()) + .addColumn("created_at", "timestamptz", (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn("updated_at", "timestamptz", (col) => + col.notNull().defaultTo(sql`now()`), + ) + .execute(); + + await db.schema + .createIndex("idx_task_board_comments_item") + .on("task_board_comments") + .columns(["organization_id", "task_board_item_id"]) + .execute(); + + await db.schema + .createTable("task_board_attachments") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("organization_id", "text", (col) => + col.notNull().references("organization.id").onDelete("cascade"), + ) + .addColumn("task_board_item_id", "text", (col) => + col.notNull().references("task_board_items.id").onDelete("cascade"), + ) + .addColumn("comment_id", "text", (col) => + col.references("task_board_comments.id").onDelete("cascade"), + ) + .addColumn("filename", "text", (col) => col.notNull()) + .addColumn("mime_type", "text", (col) => col.notNull()) + .addColumn("size", "integer", (col) => col.notNull()) + .addColumn("data", "bytea", (col) => col.notNull()) + .addColumn("created_by", "text", (col) => col.notNull()) + .addColumn("created_at", "timestamptz", (col) => + col.notNull().defaultTo(sql`now()`), + ) + .execute(); + + await db.schema + .createIndex("idx_task_board_attachments_item") + .on("task_board_attachments") + .columns(["organization_id", "task_board_item_id"]) + .execute(); + + await db.schema + .createTable("task_board_sprints") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("organization_id", "text", (col) => + col.notNull().references("organization.id").onDelete("cascade"), + ) + .addColumn("name", "text", (col) => col.notNull()) + .addColumn("state", "text", (col) => col.notNull().defaultTo("planned")) + .addColumn("start_date", "timestamptz") + .addColumn("end_date", "timestamptz") + .addColumn("created_by", "text", (col) => col.notNull()) + .addColumn("created_at", "timestamptz", (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn("updated_at", "timestamptz", (col) => + col.notNull().defaultTo(sql`now()`), + ) + .execute(); + + await db.schema + .createTable("task_board_releases") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("organization_id", "text", (col) => + col.notNull().references("organization.id").onDelete("cascade"), + ) + .addColumn("title", "text", (col) => col.notNull()) + .addColumn("notes", "text") + .addColumn("created_by", "text", (col) => col.notNull()) + .addColumn("created_at", "timestamptz", (col) => + col.notNull().defaultTo(sql`now()`), + ) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable("task_board_releases").execute(); + await db.schema.dropTable("task_board_sprints").execute(); + await db.schema.dropIndex("idx_task_board_attachments_item").execute(); + await db.schema.dropTable("task_board_attachments").execute(); + await db.schema.dropIndex("idx_task_board_comments_item").execute(); + await db.schema.dropTable("task_board_comments").execute(); + await db.schema + .alterTable("task_board_items") + .dropColumn("column_id") + .dropColumn("tags") + .dropColumn("sprint_id") + .dropColumn("release_id") + .dropColumn("automation_column_id") + .execute(); + await db.schema + .alterTable("organization_settings") + .dropColumn("task_board") + .execute(); +} diff --git a/apps/api/migrations/151-task-board-item-seq.ts b/apps/api/migrations/151-task-board-item-seq.ts new file mode 100644 index 0000000000..26d83d708b --- /dev/null +++ b/apps/api/migrations/151-task-board-item-seq.ts @@ -0,0 +1,40 @@ +import { type Kysely, sql } from "kysely"; + +/** + * Per-org sequential task number (`seq`) — a short, human-friendly key shown on + * cards and the task modal (e.g. "#42"), the way trackers surface PROJ-123. + * Assigned as max(seq)+1 per org at create time. Backfilled here by created_at + * order so existing tasks get stable numbers. + */ +export async function up(db: Kysely): Promise { + await db.schema + .alterTable("task_board_items") + .addColumn("seq", "integer") + .execute(); + + // Number existing rows per org, oldest first (id breaks created_at ties). + await sql` + UPDATE task_board_items AS t + SET seq = s.rn + FROM ( + SELECT id, + row_number() OVER ( + PARTITION BY organization_id + ORDER BY created_at ASC, id ASC + ) AS rn + FROM task_board_items + ) AS s + WHERE t.id = s.id + `.execute(db); + + await db.schema + .createIndex("idx_task_board_items_org_seq") + .on("task_board_items") + .columns(["organization_id", "seq"]) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropIndex("idx_task_board_items_org_seq").execute(); + await db.schema.alterTable("task_board_items").dropColumn("seq").execute(); +} diff --git a/apps/api/migrations/152-task-board-activity.ts b/apps/api/migrations/152-task-board-activity.ts new file mode 100644 index 0000000000..4937acbe32 --- /dev/null +++ b/apps/api/migrations/152-task-board-activity.ts @@ -0,0 +1,38 @@ +import { type Kysely, sql } from "kysely"; + +/** + * Task activity log — the "changes of the card" timeline (created, status + * moved, (re)assigned, sprint changed), rendered inline in the task's Activity + * feed alongside comments, agent sessions and PRs. Append-only; one row per + * event, with the actor and a jsonb `data` payload ({from,to,...}). + */ +export async function up(db: Kysely): Promise { + await db.schema + .createTable("task_board_activity") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("organization_id", "text", (col) => + col.notNull().references("organization.id").onDelete("cascade"), + ) + .addColumn("task_board_item_id", "text", (col) => + col.notNull().references("task_board_items.id").onDelete("cascade"), + ) + .addColumn("kind", "text", (col) => col.notNull()) + /** User id, or a sentinel ("system"/"super-agent") for machine actors. */ + .addColumn("actor_id", "text") + .addColumn("data", "jsonb") + .addColumn("created_at", "timestamptz", (col) => + col.notNull().defaultTo(sql`now()`), + ) + .execute(); + + await db.schema + .createIndex("idx_task_board_activity_item") + .on("task_board_activity") + .columns(["organization_id", "task_board_item_id", "created_at"]) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropIndex("idx_task_board_activity_item").execute(); + await db.schema.dropTable("task_board_activity").execute(); +} diff --git a/apps/api/migrations/index.ts b/apps/api/migrations/index.ts index 117b0cc4af..a1b664d1f4 100644 --- a/apps/api/migrations/index.ts +++ b/apps/api/migrations/index.ts @@ -148,6 +148,9 @@ import * as migration146organizationmainagent from "./146-organization-main-agen import * as migration147dropagentsandboxtables from "./147-drop-agent-sandbox-tables.ts"; import * as migration148orgflags from "./148-org-flags.ts"; import * as migration149taskboarditemsortorder from "./149-task-board-item-sort-order.ts"; +import * as migration150taskboardfeatures from "./150-task-board-features.ts"; +import * as migration151taskboarditemseq from "./151-task-board-item-seq.ts"; +import * as migration152taskboardactivity from "./152-task-board-activity.ts"; /** * Core migrations for the Studio application. @@ -321,6 +324,9 @@ const migrations: Record = { "147-drop-agent-sandbox-tables": migration147dropagentsandboxtables, "148-org-flags": migration148orgflags, "149-task-board-item-sort-order": migration149taskboarditemsortorder, + "150-task-board-features": migration150taskboardfeatures, + "151-task-board-item-seq": migration151taskboarditemseq, + "152-task-board-activity": migration152taskboardactivity, }; export default migrations; diff --git a/apps/api/src/api/routes/org-scoped.ts b/apps/api/src/api/routes/org-scoped.ts index 72d6998f50..68a732d39a 100644 --- a/apps/api/src/api/routes/org-scoped.ts +++ b/apps/api/src/api/routes/org-scoped.ts @@ -25,6 +25,7 @@ import { createProxyRoutes } from "./proxy"; import { createSelfRoutes } from "./self"; import { createHomeNextActionsRoutes } from "./home-next-actions"; import { createCommerceDiagnosticShareRoutes } from "./commerce-diagnostic-share"; +import { createTaskBoardAttachmentRoutes } from "./task-board-attachments"; import { createTaskBoardImportRoutes } from "./task-board-import"; import { createObjectStorageRoutes } from "./object-storage"; import { createThreadOutputsRoutes } from "./thread-outputs"; @@ -88,6 +89,7 @@ export const createOrgScopedApi = (deps: OrgScopedDeps) => { app.route("/", createDownstreamTokenRoutes()); // /api/:org/connections/:connectionId/oauth-token app.route("/", createCredentialVaultRoutes()); // /api/:org/vault/connections/:connectionId/access-token app.route("/", createTaskBoardImportRoutes()); // /api/:org/internal/task-board/import — service-token batch import + app.route("/", createTaskBoardAttachmentRoutes()); // /api/:org/task-board/attachments/:id — attachment bytes app.route("/", createCommerceDiagnosticShareRoutes()); // /api/:org/internal/commerce-diagnostic/share-invite — service-token share invite app.route("/", createThreadOutputsRoutes()); // /api/:org/threads/:threadId/outputs app.route("/tools", createToolsRestRoutes()); // /api/:org/tools[/:toolName] — REST builtin-tool dispatch diff --git a/apps/api/src/api/routes/task-board-attachments.ts b/apps/api/src/api/routes/task-board-attachments.ts new file mode 100644 index 0000000000..cfb521860a --- /dev/null +++ b/apps/api/src/api/routes/task-board-attachments.ts @@ -0,0 +1,65 @@ +/** + * Task board attachment bytes. + * + * Route: GET /api/:org/task-board/attachments/:id + * + * Uploads go through the TASK_BOARD_ATTACHMENT_ADD / TASK_BOARD_COMMENT_CREATE + * tools (base64, size-capped); this route only streams the stored bytes back + * so tags and download links work. Org membership is enforced by + * `resolveOrgFromPath` on the org-scoped sub-app; the storage read is + * additionally keyed by the resolved org id. + */ + +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import type { StudioContext } from "@/core/studio-context"; + +type Variables = { studioContext: StudioContext }; + +/** Only render-safe media inline; everything else downloads. */ +const INLINE_MIME = /^(image\/|video\/|audio\/|application\/pdf$|text\/plain$)/; + +export const createTaskBoardAttachmentRoutes = () => { + const app = new Hono<{ Variables: Variables }>(); + + app.get("/task-board/attachments/:id", async (c) => { + const ctx = c.get("studioContext"); + if (!ctx.auth?.user?.id) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + const orgId = ctx.organization?.id; + if (!orgId) { + throw new HTTPException(400, { message: "Organization required" }); + } + + const id = c.req.param("id"); + if (!id || !/^[A-Za-z0-9_-]+$/.test(id)) { + throw new HTTPException(400, { message: "Invalid attachment ID" }); + } + + const attachment = await ctx.storage.taskBoard.getAttachment(id, orgId); + if (!attachment) { + throw new HTTPException(404, { message: "Attachment not found" }); + } + + const { meta, data } = attachment; + // Re-view over a plain ArrayBuffer — Hono's Data type rejects a + // SharedArrayBuffer-backed view, which pg's driver types don't rule out. + const body = new Uint8Array(data.slice().buffer) as Uint8Array; + const inline = INLINE_MIME.test(meta.mimeType); + // RFC 5987 filename* handles non-ASCII names; plain filename is a fallback. + const asciiName = meta.filename + .replace(/[^\x20-\x7e]/g, "_") + .replace(/"/g, "'"); + return c.body(body, 200, { + "Content-Type": inline ? meta.mimeType : "application/octet-stream", + "Content-Length": String(meta.size), + "Content-Disposition": `${inline ? "inline" : "attachment"}; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(meta.filename)}`, + // Attachment bytes are immutable (delete + re-add mints a new id). + "Cache-Control": "private, max-age=31536000, immutable", + "X-Content-Type-Options": "nosniff", + }); + }); + + return app; +}; diff --git a/apps/api/src/storage/organization-settings.ts b/apps/api/src/storage/organization-settings.ts index dd47eb2d67..d44d5bab9b 100644 --- a/apps/api/src/storage/organization-settings.ts +++ b/apps/api/src/storage/organization-settings.ts @@ -51,6 +51,11 @@ export class OrganizationSettingsStorage : record.flags : null, main_agent_id: record.main_agent_id ?? null, + task_board: record.task_board + ? typeof record.task_board === "string" + ? JSON.parse(record.task_board) + : record.task_board + : null, createdAt: record.createdAt, updatedAt: record.updatedAt, }; @@ -68,6 +73,7 @@ export class OrganizationSettingsStorage | "default_home_agents" | "flags" | "main_agent_id" + | "task_board" > >, ): Promise { @@ -88,6 +94,9 @@ export class OrganizationSettingsStorage ? JSON.stringify(data.default_home_agents) : null; const flagsJson = data?.flags ? JSON.stringify(data.flags) : null; + const taskBoardJson = data?.task_board + ? JSON.stringify(data.task_board) + : null; await this.db .insertInto("organization_settings") .values({ @@ -99,6 +108,7 @@ export class OrganizationSettingsStorage default_home_agents: defaultHomeAgentsJson, flags: flagsJson, main_agent_id: data?.main_agent_id ?? null, + task_board: taskBoardJson, createdAt: now, updatedAt: now, }) @@ -120,6 +130,7 @@ export class OrganizationSettingsStorage // Nullable id: explicit `null` clears the main agent; `undefined` // (field absent) skips the column so partial updates don't wipe it. main_agent_id: data?.main_agent_id, + task_board: taskBoardJson ? taskBoardJson : undefined, updatedAt: now, }), ) @@ -137,6 +148,7 @@ export class OrganizationSettingsStorage default_home_agents: data?.default_home_agents ?? null, flags: data?.flags ?? null, main_agent_id: data?.main_agent_id ?? null, + task_board: data?.task_board ?? null, createdAt: now, updatedAt: now, }; diff --git a/apps/api/src/storage/ports.ts b/apps/api/src/storage/ports.ts index 8d8851918c..26b1431f2e 100644 --- a/apps/api/src/storage/ports.ts +++ b/apps/api/src/storage/ports.ts @@ -310,6 +310,7 @@ export interface OrganizationSettingsStoragePort { | "default_home_agents" | "flags" | "main_agent_id" + | "task_board" > >, ): Promise; diff --git a/apps/api/src/storage/task-board.ts b/apps/api/src/storage/task-board.ts index 43ed603b62..2c75ea5685 100644 --- a/apps/api/src/storage/task-board.ts +++ b/apps/api/src/storage/task-board.ts @@ -8,14 +8,36 @@ import type { Kysely } from "kysely"; import type { Database, + TaskBoardActivity, + TaskBoardActivityKind, + TaskBoardAttachmentMeta, + TaskBoardComment, TaskBoardItem, TaskBoardItemPriority, TaskBoardItemPrRef, TaskBoardItemStatus, TaskBoardItemThreadRef, + TaskBoardRelease, + TaskBoardSprint, + TaskBoardSprintState, } from "./types"; import { generatePrefixedId } from "@decocms/shared/utils/generate-id"; +function toIso(value: Date | string): string { + return value instanceof Date ? value.toISOString() : value; +} + +function toIsoOrNull(value: Date | string | null): string | null { + return value === null ? null : toIso(value); +} + +/** Jsonb string array — pg may hand back a parsed array or a JSON string. */ +function parseTags(value: unknown): string[] { + const raw = typeof value === "string" ? JSON.parse(value) : value; + if (!Array.isArray(raw)) return []; + return raw.filter((t): t is string => typeof t === "string"); +} + /** Text of a folded/persisted text part payload (`{ type: "text", text }`). */ function extractPartText(payload: unknown): string | null { if (payload && typeof payload === "object" && !Array.isArray(payload)) { @@ -121,6 +143,9 @@ export class TaskBoardStorage { dueDate?: string | null; /** Sender-minted finding identity — see task-board-import. */ externalKey?: string | null; + columnId?: string | null; + tags?: string[]; + sprintId?: string | null; by: string; }): Promise { const id = generatePrefixedId("board"); @@ -136,6 +161,16 @@ export class TaskBoardStorage { .where("status", "=", status) .executeTakeFirstOrThrow(); + // Per-org short key: max(seq)+1. Race window is negligible at this scale; + // the org+seq index would surface a collision as an error rather than a + // silent dup if it ever bit. + const maxSeq = await this.db + .selectFrom("task_board_items") + .select((eb) => eb.fn.max("seq").as("maxSeq")) + .where("organization_id", "=", params.organizationId) + .executeTakeFirst(); + const seq = (maxSeq?.maxSeq ?? 0) + 1; + const row = await this.db .insertInto("task_board_items") .values({ @@ -150,6 +185,10 @@ export class TaskBoardStorage { due_date: params.dueDate ?? null, external_key: params.externalKey ?? null, sort_order: (minOrder ?? 0) - 1, + seq, + column_id: params.columnId ?? null, + tags: JSON.stringify(params.tags ?? []), + sprint_id: params.sprintId ?? null, created_by: params.by, created_at: now, updated_by: params.by, @@ -174,9 +213,23 @@ export class TaskBoardStorage { assignedBy?: string | null; dueDate?: string | null; sortOrder?: number; + columnId?: string | null; + tags?: string[]; + sprintId?: string | null; + releaseId?: string | null; + automationColumnId?: string | null; }, by: string, ): Promise { + // A status change without an explicit column placement (a run-driven + // advance, or a default-board move) clears column_id so the card lands in + // that stage's first configured column instead of a stale custom one. + const columnId = + data.columnId !== undefined + ? data.columnId + : data.status !== undefined + ? null + : undefined; const row = await this.db .updateTable("task_board_items") .set({ @@ -194,6 +247,13 @@ export class TaskBoardStorage { : {}), ...(data.dueDate !== undefined ? { due_date: data.dueDate } : {}), ...(data.sortOrder !== undefined ? { sort_order: data.sortOrder } : {}), + ...(columnId !== undefined ? { column_id: columnId } : {}), + ...(data.tags !== undefined ? { tags: JSON.stringify(data.tags) } : {}), + ...(data.sprintId !== undefined ? { sprint_id: data.sprintId } : {}), + ...(data.releaseId !== undefined ? { release_id: data.releaseId } : {}), + ...(data.automationColumnId !== undefined + ? { automation_column_id: data.automationColumnId } + : {}), updated_by: by, updated_at: new Date().toISOString(), }) @@ -474,6 +534,13 @@ export class TaskBoardStorage { assigned_by: string | null; due_date: string | Date | null; sort_order: number; + external_key: string | null; + seq: number | null; + column_id: string | null; + tags: unknown; + sprint_id: string | null; + release_id: string | null; + automation_column_id: string | null; created_by: string; created_at: string | Date; updated_by: string; @@ -488,23 +555,490 @@ export class TaskBoardStorage { priority: row.priority as TaskBoardItemPriority, assigneeId: row.assignee_id, assignedBy: row.assigned_by, - dueDate: - row.due_date instanceof Date - ? row.due_date.toISOString() - : row.due_date, + dueDate: toIsoOrNull(row.due_date), sortOrder: row.sort_order, + externalKey: row.external_key, + seq: row.seq, + columnId: row.column_id, + tags: parseTags(row.tags), + sprintId: row.sprint_id, + releaseId: row.release_id, + automationColumnId: row.automation_column_id, // Populated by attachThreads for reads; empty for a fresh create. threads: [], createdBy: row.created_by, - createdAt: - row.created_at instanceof Date - ? row.created_at.toISOString() - : row.created_at, + createdAt: toIso(row.created_at), updatedBy: row.updated_by, - updatedAt: - row.updated_at instanceof Date - ? row.updated_at.toISOString() - : row.updated_at, + updatedAt: toIso(row.updated_at), + }; + } + + // -------------------------------------------------------------------------- + // Comments + // -------------------------------------------------------------------------- + + async createComment(params: { + organizationId: string; + taskBoardItemId: string; + parentId?: string | null; + body: string; + by: string; + }): Promise { + const now = new Date().toISOString(); + const row = await this.db + .insertInto("task_board_comments") + .values({ + id: generatePrefixedId("cmt"), + organization_id: params.organizationId, + task_board_item_id: params.taskBoardItemId, + parent_id: params.parentId ?? null, + body: params.body, + created_by: params.by, + created_at: now, + updated_at: now, + }) + .returningAll() + .executeTakeFirstOrThrow(); + return this.commentFromDbRow(row); + } + + /** Comments for a task, oldest first, each with its attachment metadata. */ + async listComments( + taskBoardItemId: string, + organizationId: string, + ): Promise { + const rows = await this.db + .selectFrom("task_board_comments") + .selectAll() + .where("organization_id", "=", organizationId) + .where("task_board_item_id", "=", taskBoardItemId) + .orderBy("created_at", "asc") + .execute(); + const comments = rows.map((row) => this.commentFromDbRow(row)); + if (comments.length > 0) { + const attachments = await this.listAttachments( + taskBoardItemId, + organizationId, + ); + const byComment = new Map(); + for (const a of attachments) { + if (!a.commentId) continue; + const list = byComment.get(a.commentId); + if (list) list.push(a); + else byComment.set(a.commentId, [a]); + } + for (const c of comments) c.attachments = byComment.get(c.id) ?? []; + } + return comments; + } + + async getCommentById( + id: string, + organizationId: string, + ): Promise { + const row = await this.db + .selectFrom("task_board_comments") + .selectAll() + .where("id", "=", id) + .where("organization_id", "=", organizationId) + .executeTakeFirst(); + return row ? this.commentFromDbRow(row) : null; + } + + async updateComment( + id: string, + organizationId: string, + body: string, + ): Promise { + const row = await this.db + .updateTable("task_board_comments") + .set({ body, updated_at: new Date().toISOString() }) + .where("id", "=", id) + .where("organization_id", "=", organizationId) + .returningAll() + .executeTakeFirstOrThrow(); + return this.commentFromDbRow(row); + } + + /** Delete a comment. Replies and comment attachments cascade in the DB. */ + async deleteComment(id: string, organizationId: string): Promise { + await this.db + .deleteFrom("task_board_comments") + .where("id", "=", id) + .where("organization_id", "=", organizationId) + .execute(); + } + + // -------------------------------------------------------------------------- + // Attachments + // -------------------------------------------------------------------------- + + async addAttachment(params: { + organizationId: string; + taskBoardItemId: string; + commentId?: string | null; + filename: string; + mimeType: string; + data: Uint8Array; + by: string; + }): Promise { + const row = await this.db + .insertInto("task_board_attachments") + .values({ + id: generatePrefixedId("att"), + organization_id: params.organizationId, + task_board_item_id: params.taskBoardItemId, + comment_id: params.commentId ?? null, + filename: params.filename, + mime_type: params.mimeType, + size: params.data.byteLength, + data: params.data, + created_by: params.by, + created_at: new Date().toISOString(), + }) + .returning([ + "id", + "task_board_item_id", + "comment_id", + "filename", + "mime_type", + "size", + "created_by", + "created_at", + ]) + .executeTakeFirstOrThrow(); + return this.attachmentMetaFromDbRow(row); + } + + /** All attachment metadata for a task (task-level and comment-level). */ + async listAttachments( + taskBoardItemId: string, + organizationId: string, + ): Promise { + const rows = await this.db + .selectFrom("task_board_attachments") + .select([ + "id", + "task_board_item_id", + "comment_id", + "filename", + "mime_type", + "size", + "created_by", + "created_at", + ]) + .where("organization_id", "=", organizationId) + .where("task_board_item_id", "=", taskBoardItemId) + .orderBy("created_at", "asc") + .execute(); + return rows.map((row) => this.attachmentMetaFromDbRow(row)); + } + + /** Full attachment (metadata + bytes) — for the serving route only. */ + async getAttachment( + id: string, + organizationId: string, + ): Promise<{ meta: TaskBoardAttachmentMeta; data: Uint8Array } | null> { + const row = await this.db + .selectFrom("task_board_attachments") + .selectAll() + .where("id", "=", id) + .where("organization_id", "=", organizationId) + .executeTakeFirst(); + if (!row) return null; + return { meta: this.attachmentMetaFromDbRow(row), data: row.data }; + } + + async deleteAttachment(id: string, organizationId: string): Promise { + await this.db + .deleteFrom("task_board_attachments") + .where("id", "=", id) + .where("organization_id", "=", organizationId) + .execute(); + } + + // -------------------------------------------------------------------------- + // Sprints + // -------------------------------------------------------------------------- + + async createSprint(params: { + organizationId: string; + name: string; + state?: TaskBoardSprintState; + startDate?: string | null; + endDate?: string | null; + by: string; + }): Promise { + const now = new Date().toISOString(); + const row = await this.db + .insertInto("task_board_sprints") + .values({ + id: generatePrefixedId("sprint"), + organization_id: params.organizationId, + name: params.name, + state: params.state ?? "planned", + start_date: params.startDate ?? null, + end_date: params.endDate ?? null, + created_by: params.by, + created_at: now, + updated_at: now, + }) + .returningAll() + .executeTakeFirstOrThrow(); + return this.sprintFromDbRow(row); + } + + async updateSprint( + id: string, + organizationId: string, + data: { + name?: string; + state?: TaskBoardSprintState; + startDate?: string | null; + endDate?: string | null; + }, + ): Promise { + const row = await this.db + .updateTable("task_board_sprints") + .set({ + ...(data.name !== undefined ? { name: data.name } : {}), + ...(data.state !== undefined ? { state: data.state } : {}), + ...(data.startDate !== undefined ? { start_date: data.startDate } : {}), + ...(data.endDate !== undefined ? { end_date: data.endDate } : {}), + updated_at: new Date().toISOString(), + }) + .where("id", "=", id) + .where("organization_id", "=", organizationId) + .returningAll() + .executeTakeFirstOrThrow(); + return this.sprintFromDbRow(row); + } + + /** Delete a sprint and unstamp its tasks (they fall back to the backlog). */ + async deleteSprint(id: string, organizationId: string): Promise { + await this.db.transaction().execute(async (trx) => { + await trx + .updateTable("task_board_items") + .set({ sprint_id: null }) + .where("sprint_id", "=", id) + .where("organization_id", "=", organizationId) + .execute(); + await trx + .deleteFrom("task_board_sprints") + .where("id", "=", id) + .where("organization_id", "=", organizationId) + .execute(); + }); + } + + /** Sprints for the org — active first, then planned, then closed. */ + async listSprints(organizationId: string): Promise { + const rows = await this.db + .selectFrom("task_board_sprints") + .selectAll() + .where("organization_id", "=", organizationId) + .orderBy("created_at", "desc") + .execute(); + const order: Record = { + active: 0, + planned: 1, + closed: 2, + }; + return rows + .map((row) => this.sprintFromDbRow(row)) + .sort((a, b) => order[a.state] - order[b.state]); + } + + // -------------------------------------------------------------------------- + // Releases + // -------------------------------------------------------------------------- + + /** Create a release and stamp the given tasks with it, atomically. */ + async createRelease(params: { + organizationId: string; + title: string; + notes?: string | null; + taskIds: string[]; + by: string; + }): Promise { + return await this.db.transaction().execute(async (trx) => { + const row = await trx + .insertInto("task_board_releases") + .values({ + id: generatePrefixedId("rel"), + organization_id: params.organizationId, + title: params.title, + notes: params.notes ?? null, + created_by: params.by, + created_at: new Date().toISOString(), + }) + .returningAll() + .executeTakeFirstOrThrow(); + if (params.taskIds.length > 0) { + await trx + .updateTable("task_board_items") + .set({ release_id: row.id }) + .where("id", "in", params.taskIds) + .where("organization_id", "=", params.organizationId) + .execute(); + } + return this.releaseFromDbRow(row); + }); + } + + async listReleases(organizationId: string): Promise { + const rows = await this.db + .selectFrom("task_board_releases") + .selectAll() + .where("organization_id", "=", organizationId) + .orderBy("created_at", "desc") + .execute(); + return rows.map((row) => this.releaseFromDbRow(row)); + } + + /** Delete a release and unstamp its tasks. */ + async deleteRelease(id: string, organizationId: string): Promise { + await this.db.transaction().execute(async (trx) => { + await trx + .updateTable("task_board_items") + .set({ release_id: null }) + .where("release_id", "=", id) + .where("organization_id", "=", organizationId) + .execute(); + await trx + .deleteFrom("task_board_releases") + .where("id", "=", id) + .where("organization_id", "=", organizationId) + .execute(); + }); + } + + // -------------------------------------------------------------------------- + // Activity log (the card's change timeline) + // -------------------------------------------------------------------------- + + /** Append one activity event. Best-effort at the call site — never let a log + * write fail the change it describes. */ + async recordActivity(params: { + organizationId: string; + taskBoardItemId: string; + kind: TaskBoardActivityKind; + actorId: string | null; + data?: Record; + }): Promise { + await this.db + .insertInto("task_board_activity") + .values({ + id: generatePrefixedId("act"), + organization_id: params.organizationId, + task_board_item_id: params.taskBoardItemId, + kind: params.kind, + actor_id: params.actorId, + data: params.data ? JSON.stringify(params.data) : null, + created_at: new Date().toISOString(), + }) + .execute(); + } + + /** A task's activity, oldest first (timeline order). */ + async listActivity( + taskBoardItemId: string, + organizationId: string, + ): Promise { + const rows = await this.db + .selectFrom("task_board_activity") + .selectAll() + .where("organization_id", "=", organizationId) + .where("task_board_item_id", "=", taskBoardItemId) + .orderBy("created_at", "asc") + .execute(); + return rows.map((row) => ({ + id: row.id, + taskBoardItemId: row.task_board_item_id, + kind: row.kind as TaskBoardActivityKind, + actorId: row.actor_id, + data: + typeof row.data === "string" ? JSON.parse(row.data) : (row.data ?? {}), + createdAt: toIso(row.created_at), + })); + } + + private commentFromDbRow(row: { + id: string; + task_board_item_id: string; + parent_id: string | null; + body: string; + created_by: string; + created_at: string | Date; + updated_at: string | Date; + }): TaskBoardComment { + return { + id: row.id, + taskBoardItemId: row.task_board_item_id, + parentId: row.parent_id, + body: row.body, + attachments: [], + createdBy: row.created_by, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; + } + + private attachmentMetaFromDbRow(row: { + id: string; + task_board_item_id: string; + comment_id: string | null; + filename: string; + mime_type: string; + size: number; + created_by: string; + created_at: string | Date; + }): TaskBoardAttachmentMeta { + return { + id: row.id, + taskBoardItemId: row.task_board_item_id, + commentId: row.comment_id, + filename: row.filename, + mimeType: row.mime_type, + size: row.size, + createdBy: row.created_by, + createdAt: toIso(row.created_at), + }; + } + + private sprintFromDbRow(row: { + id: string; + name: string; + state: string; + start_date: string | Date | null; + end_date: string | Date | null; + created_by: string; + created_at: string | Date; + }): TaskBoardSprint { + return { + id: row.id, + name: row.name, + state: row.state as TaskBoardSprintState, + startDate: toIsoOrNull(row.start_date), + endDate: toIsoOrNull(row.end_date), + createdBy: row.created_by, + createdAt: toIso(row.created_at), + }; + } + + private releaseFromDbRow(row: { + id: string; + title: string; + notes: string | null; + created_by: string; + created_at: string | Date; + }): TaskBoardRelease { + return { + id: row.id, + title: row.title, + notes: row.notes, + createdBy: row.created_by, + createdAt: toIso(row.created_at), }; } } diff --git a/apps/api/src/storage/types.ts b/apps/api/src/storage/types.ts index 37aa9f5cbb..2ed176b8cf 100644 --- a/apps/api/src/storage/types.ts +++ b/apps/api/src/storage/types.ts @@ -173,6 +173,29 @@ export interface DefaultHomeAgentsConfig { ids: string[]; } +/** One board column in the org's task-board config. `stage` maps the column + * onto the canonical 5-stage lifecycle so run-driven automation (todo → + * in_progress → in_review) keeps working over custom columns. */ +export interface TaskBoardColumnConfig { + /** Stable id. The default columns use their stage name as id. */ + id: string; + /** Display label. Null for a default column — the UI uses its i18n label. */ + name: string | null; + stage: TaskBoardItemStatus; + /** When enabled, a task entering this column enqueues an agent run on it. + * `agentId` is a virtual MCP id; null = the org's Super Agent. */ + automation?: { enabled: boolean; agentId: string | null }; +} + +/** Per-org task board configuration (`organization_settings.task_board`). + * Null (row or field) = the default simple board. */ +export interface TaskBoardSettingsConfig { + /** Custom column set, in board order. Null = the default 5 columns. */ + columns: TaskBoardColumnConfig[] | null; + sprintsEnabled?: boolean; + releasesEnabled?: boolean; +} + export interface OrganizationSettingsTable { organizationId: string; sidebar_items: JsonArray | null; @@ -185,6 +208,7 @@ export interface OrganizationSettingsTable { flags: JsonObject | null; // Virtual MCP id the org lands on (`/$org`) instead of the Super Agent. main_agent_id: string | null; + task_board: JsonObject | null; createdAt: ColumnType; updatedAt: ColumnType; } @@ -198,6 +222,7 @@ export interface OrganizationSettings { default_home_agents: DefaultHomeAgentsConfig | null; flags: OrgFlags | null; main_agent_id: string | null; + task_board: TaskBoardSettingsConfig | null; createdAt: Date | string; updatedAt: Date | string; } @@ -1578,11 +1603,20 @@ export interface OrgSite { updatedAt: string; } +/** + * Canonical delivery-lifecycle stage of a task. The 5 original values plus the + * richer tail (qa/ready_for_release/deploy) so external trackers can map their + * statuses into distinct positions. Stored in the plain-text `status` column + * (no DB enum) — see TASK_BOARD_STAGES for the ordered ladder. + */ export type TaskBoardItemStatus = | "triage" | "todo" | "in_progress" | "in_review" + | "qa" + | "ready_for_release" + | "deploy" | "done"; export type TaskBoardItemPriority = @@ -1624,12 +1658,178 @@ export interface TaskBoardItemTable { >; /** Manual drag-to-reorder position within a lane, ascending. */ sort_order: ColumnType; + /** Per-org sequential number, the task's short key ("#42"). Assigned + * max(seq)+1 per org at create; backfilled for existing rows. Nullable so a + * row written before the column existed (or by an un-reloaded server) never + * hard-fails a read — the UI just omits the key until it's assigned. */ + seq: ColumnType; + /** Custom-column placement. Null = derive the column from `status` (the + * first configured column with that stage — the default board's case). */ + column_id: ColumnType< + string | null, + string | null | undefined, + string | null + >; + /** Free-form labels (jsonb string array). */ + tags: ColumnType; + sprint_id: ColumnType< + string | null, + string | null | undefined, + string | null + >; + release_id: ColumnType< + string | null, + string | null | undefined, + string | null + >; + /** Column whose automation last enqueued a run for this task — the guard + * that keeps a run-driven bounce (QA reopens → finishes → In Review again) + * from re-triggering the same column's agent forever. Cleared on a human + * column move. */ + automation_column_id: ColumnType< + string | null, + string | null | undefined, + string | null + >; created_by: string; created_at: ColumnType; updated_by: string; updated_at: ColumnType; } +/** A comment on a task board item. One level of replies via `parent_id`. */ +export interface TaskBoardCommentTable { + id: string; + organization_id: string; + task_board_item_id: string; + parent_id: string | null; + body: string; + created_by: string; + created_at: ColumnType; + updated_at: ColumnType; +} + +/** A file/image attached to a task (or to one of its comments). Bytes live in + * `data` (size-capped at the tool layer) and are served by an org-scoped + * route, never inlined into tool outputs. */ +export interface TaskBoardAttachmentTable { + id: string; + organization_id: string; + task_board_item_id: string; + comment_id: string | null; + filename: string; + mime_type: string; + size: number; + data: Uint8Array; + created_by: string; + created_at: ColumnType; +} + +export type TaskBoardSprintState = "planned" | "active" | "closed"; + +export interface TaskBoardSprintTable { + id: string; + organization_id: string; + name: string; + state: ColumnType; + start_date: ColumnType< + Date | null, + Date | string | null | undefined, + Date | string | null + >; + end_date: ColumnType< + Date | null, + Date | string | null | undefined, + Date | string | null + >; + created_by: string; + created_at: ColumnType; + updated_at: ColumnType; +} + +export interface TaskBoardReleaseTable { + id: string; + organization_id: string; + title: string; + notes: string | null; + created_by: string; + created_at: ColumnType; +} + +/** One entry in a task's change timeline. */ +export type TaskBoardActivityKind = + | "created" + | "status_changed" + | "assignee_changed" + | "sprint_changed"; + +export interface TaskBoardActivityTable { + id: string; + organization_id: string; + task_board_item_id: string; + kind: string; + /** User id, or a sentinel ("system" / SUPER_AGENT_ASSIGNEE_ID). */ + actor_id: string | null; + data: ColumnType< + Record | null, + string | null | undefined, + string | null + >; + created_at: ColumnType; +} + +export interface TaskBoardActivity { + id: string; + taskBoardItemId: string; + kind: TaskBoardActivityKind; + actorId: string | null; + /** Event payload — e.g. { from, to } for a status/assignee/sprint change. */ + data: Record; + createdAt: string; +} + +export interface TaskBoardComment { + id: string; + taskBoardItemId: string; + parentId: string | null; + body: string; + attachments: TaskBoardAttachmentMeta[]; + createdBy: string; + createdAt: string; + updatedAt: string; +} + +/** Attachment identity + metadata — bytes are served by the org-scoped + * `/task-board/attachments/:id` route. */ +export interface TaskBoardAttachmentMeta { + id: string; + taskBoardItemId: string; + commentId: string | null; + filename: string; + mimeType: string; + size: number; + createdBy: string; + createdAt: string; +} + +export interface TaskBoardSprint { + id: string; + name: string; + state: TaskBoardSprintState; + startDate: string | null; + endDate: string | null; + createdBy: string; + createdAt: string; +} + +export interface TaskBoardRelease { + id: string; + title: string; + notes: string | null; + createdBy: string; + createdAt: string; +} + /** One processed task-board import request: PK (organization_id, run_id). * Claimed inside the import's transaction — a replay of the same run (the * reports worker's payment success page and Stripe webhook both push) loses @@ -1705,6 +1905,20 @@ export interface TaskBoardItem { dueDate: string | null; /** Manual drag-to-reorder position within a lane, ascending. */ sortOrder: number; + /** Per-org sequential number — the task's short key. Null only for a legacy + * row not yet assigned one (the UI omits the key then). */ + seq: number | null; + /** Custom-column placement; null = derive from `status`. */ + columnId: string | null; + tags: string[]; + sprintId: string | null; + releaseId: string | null; + /** Sender-minted external identity (e.g. `jira:PROJ-123`, `diag:...`). Set by + * an importing connector/sync; exposed read-only so the same connector can + * correlate a board task back to its source and write changes upstream. */ + externalKey: string | null; + /** Automation re-trigger guard — internal, not exposed by tool schemas. */ + automationColumnId: string | null; /** Agent threads linked to this task (most-recent first). */ threads: TaskBoardItemThreadRef[]; createdBy: string; @@ -1867,6 +2081,11 @@ export interface Database extends PrivateRegistryDatabase { task_board_item_threads: TaskBoardItemThreadTable; task_board_item_prs: TaskBoardItemPrTable; task_board_import_runs: TaskBoardImportRunTable; + task_board_comments: TaskBoardCommentTable; + task_board_attachments: TaskBoardAttachmentTable; + task_board_sprints: TaskBoardSprintTable; + task_board_releases: TaskBoardReleaseTable; + task_board_activity: TaskBoardActivityTable; sandbox_runner_state: SandboxProviderStateTable; } diff --git a/apps/api/src/tools/index.ts b/apps/api/src/tools/index.ts index b0fb153baa..eecf4ae36d 100644 --- a/apps/api/src/tools/index.ts +++ b/apps/api/src/tools/index.ts @@ -55,6 +55,21 @@ export const CORE_TOOLS = [ TaskBoardTools.TASK_BOARD_ITEM_UPDATE, TaskBoardTools.TASK_BOARD_ITEM_DELETE, TaskBoardTools.TASK_BOARD_ITEM_PRS_GET, + TaskBoardTools.TASK_BOARD_COMMENT_CREATE, + TaskBoardTools.TASK_BOARD_COMMENT_LIST, + TaskBoardTools.TASK_BOARD_COMMENT_UPDATE, + TaskBoardTools.TASK_BOARD_COMMENT_DELETE, + TaskBoardTools.TASK_BOARD_ATTACHMENT_ADD, + TaskBoardTools.TASK_BOARD_ATTACHMENT_LIST, + TaskBoardTools.TASK_BOARD_ATTACHMENT_DELETE, + TaskBoardTools.TASK_BOARD_SPRINT_CREATE, + TaskBoardTools.TASK_BOARD_SPRINT_UPDATE, + TaskBoardTools.TASK_BOARD_SPRINT_DELETE, + TaskBoardTools.TASK_BOARD_SPRINT_LIST, + TaskBoardTools.TASK_BOARD_RELEASE_CREATE, + TaskBoardTools.TASK_BOARD_RELEASE_LIST, + TaskBoardTools.TASK_BOARD_RELEASE_DELETE, + TaskBoardTools.TASK_BOARD_ACTIVITY_LIST, OrganizationTools.BRAND_CONTEXT_LIST, OrganizationTools.BRAND_CONTEXT_GET, OrganizationTools.BRAND_CONTEXT_CREATE, diff --git a/apps/api/src/tools/organization/settings-get.ts b/apps/api/src/tools/organization/settings-get.ts index 62c57d41ef..052b8c2b5a 100644 --- a/apps/api/src/tools/organization/settings-get.ts +++ b/apps/api/src/tools/organization/settings-get.ts @@ -7,6 +7,7 @@ import { SimpleModeConfigSchema, DefaultHomeAgentsConfigSchema, OrgFlagsSchema, + TaskBoardSettingsConfigSchema, } from "@decocms/shared/organization/schema"; export const ORGANIZATION_SETTINGS_GET = defineTool({ @@ -31,6 +32,7 @@ export const ORGANIZATION_SETTINGS_GET = defineTool({ default_home_agents: DefaultHomeAgentsConfigSchema.nullable().optional(), flags: OrgFlagsSchema.nullable().optional(), main_agent_id: z.string().nullable().optional(), + task_board: TaskBoardSettingsConfigSchema.nullable().optional(), createdAt: z.string().datetime().optional().describe("ISO 8601 timestamp"), updatedAt: z.string().datetime().optional().describe("ISO 8601 timestamp"), }), diff --git a/apps/api/src/tools/organization/settings-update.ts b/apps/api/src/tools/organization/settings-update.ts index 07bc14611c..8b1663b144 100644 --- a/apps/api/src/tools/organization/settings-update.ts +++ b/apps/api/src/tools/organization/settings-update.ts @@ -7,6 +7,7 @@ import { SimpleModeConfigSchema, DefaultHomeAgentsConfigSchema, OrgFlagsSchema, + TaskBoardSettingsConfigSchema, } from "@decocms/shared/organization/schema"; export const ORGANIZATION_SETTINGS_UPDATE = defineTool({ @@ -42,6 +43,7 @@ export const ORGANIZATION_SETTINGS_UPDATE = defineTool({ .describe( "Virtual MCP id the org lands on instead of the Super Agent. Pass null to clear.", ), + task_board: TaskBoardSettingsConfigSchema.optional(), }), outputSchema: z.object({ @@ -53,6 +55,7 @@ export const ORGANIZATION_SETTINGS_UPDATE = defineTool({ default_home_agents: DefaultHomeAgentsConfigSchema.nullable().optional(), flags: OrgFlagsSchema.nullable().optional(), main_agent_id: z.string().nullable().optional(), + task_board: TaskBoardSettingsConfigSchema.nullable().optional(), createdAt: z.string().datetime().describe("ISO 8601 timestamp"), updatedAt: z.string().datetime().describe("ISO 8601 timestamp"), }), @@ -80,6 +83,7 @@ export const ORGANIZATION_SETTINGS_UPDATE = defineTool({ default_home_agents: input.default_home_agents, flags: input.flags, main_agent_id: input.main_agent_id, + task_board: input.task_board, }, ); diff --git a/apps/api/src/tools/task-board/activity.ts b/apps/api/src/tools/task-board/activity.ts new file mode 100644 index 0000000000..0fe01b63cb --- /dev/null +++ b/apps/api/src/tools/task-board/activity.ts @@ -0,0 +1,62 @@ +/** + * Task activity log — the card's change timeline (created, status moved, + * (re)assigned, sprint changed). `recordTaskActivity` is the best-effort writer + * shared by the create/update tools and the run reactions; the list tool feeds + * the Activity feed in the task dialog. + */ + +import { z } from "zod"; +import { defineTool } from "@/core/define-tool"; +import { requireAuth } from "@/core/studio-context"; +import type { StudioContext } from "@/core/studio-context"; +import type { TaskBoardActivityKind } from "@/storage/types"; +import { TaskBoardActivitySchema } from "./schema"; + +/** Append an activity event, swallowing failures — a log write must never fail + * the change it describes. */ +export async function recordTaskActivity( + ctx: StudioContext, + params: { + organizationId: string; + taskBoardItemId: string; + kind: TaskBoardActivityKind; + actorId: string | null; + data?: Record; + }, +): Promise { + try { + await ctx.storage.taskBoard.recordActivity(params); + } catch (err) { + console.error("[task-board] activity log write failed", err); + } +} + +export const TASK_BOARD_ACTIVITY_LIST = defineTool({ + name: "TASK_BOARD_ACTIVITY_LIST", + description: + "List a task board item's change history (timeline, oldest first).", + annotations: { + title: "List Task Activity", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: z.object({ taskBoardItemId: z.string() }), + outputSchema: z.object({ activity: z.array(TaskBoardActivitySchema) }), + handler: async (input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + const organizationId = ctx.organization?.id; + if (!organizationId) { + throw new Error( + "Organization ID required (no active organization in context)", + ); + } + const activity = await ctx.storage.taskBoard.listActivity( + input.taskBoardItemId, + organizationId, + ); + return { activity }; + }, +}); diff --git a/apps/api/src/tools/task-board/attachments.ts b/apps/api/src/tools/task-board/attachments.ts new file mode 100644 index 0000000000..d840c8f307 --- /dev/null +++ b/apps/api/src/tools/task-board/attachments.ts @@ -0,0 +1,117 @@ +/** + * Task attachments — files/images on a task board item. Uploads arrive as + * base64 through the tool (size-capped); bytes are served by the org-scoped + * `GET /api/:org/task-board/attachments/:id` route, never inlined in tool + * outputs. + */ + +import { z } from "zod"; +import { defineTool } from "@/core/define-tool"; +import { getUserId, requireAuth } from "@/core/studio-context"; +import { + TaskBoardAttachmentMetaSchema, + TaskBoardAttachmentUploadSchema, +} from "./schema"; +import { decodeAttachmentUpload } from "./comments"; + +export const TASK_BOARD_ATTACHMENT_ADD = defineTool({ + name: "TASK_BOARD_ATTACHMENT_ADD", + description: + "Attach a file or image to a task board item. Content is base64-encoded, capped at 10MB.", + annotations: { + title: "Add Task Attachment", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + inputSchema: TaskBoardAttachmentUploadSchema.extend({ + taskBoardItemId: z.string(), + }), + outputSchema: z.object({ attachment: TaskBoardAttachmentMetaSchema }), + handler: async (input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + const organizationId = ctx.organization?.id; + if (!organizationId) { + throw new Error( + "Organization ID required (no active organization in context)", + ); + } + const task = await ctx.storage.taskBoard.getById( + input.taskBoardItemId, + organizationId, + ); + if (!task) { + throw new Error(`Task board item not found: ${input.taskBoardItemId}`); + } + + const attachment = await ctx.storage.taskBoard.addAttachment({ + organizationId, + taskBoardItemId: input.taskBoardItemId, + filename: input.filename, + mimeType: input.mimeType, + data: decodeAttachmentUpload(input), + by: getUserId(ctx)!, + }); + return { attachment }; + }, +}); + +export const TASK_BOARD_ATTACHMENT_LIST = defineTool({ + name: "TASK_BOARD_ATTACHMENT_LIST", + description: + "List attachment metadata for a task board item (task-level and comment-level).", + annotations: { + title: "List Task Attachments", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: z.object({ taskBoardItemId: z.string() }), + outputSchema: z.object({ + attachments: z.array(TaskBoardAttachmentMetaSchema), + }), + handler: async (input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + const organizationId = ctx.organization?.id; + if (!organizationId) { + throw new Error( + "Organization ID required (no active organization in context)", + ); + } + const attachments = await ctx.storage.taskBoard.listAttachments( + input.taskBoardItemId, + organizationId, + ); + return { attachments }; + }, +}); + +export const TASK_BOARD_ATTACHMENT_DELETE = defineTool({ + name: "TASK_BOARD_ATTACHMENT_DELETE", + description: "Delete an attachment from a task board item.", + annotations: { + title: "Delete Task Attachment", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: z.object({ id: z.string() }), + outputSchema: z.object({ success: z.boolean() }), + handler: async (input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + const organizationId = ctx.organization?.id; + if (!organizationId) { + throw new Error( + "Organization ID required (no active organization in context)", + ); + } + await ctx.storage.taskBoard.deleteAttachment(input.id, organizationId); + return { success: true }; + }, +}); diff --git a/apps/api/src/tools/task-board/column-automation.ts b/apps/api/src/tools/task-board/column-automation.ts new file mode 100644 index 0000000000..0eb28553c3 --- /dev/null +++ b/apps/api/src/tools/task-board/column-automation.ts @@ -0,0 +1,68 @@ +/** + * Per-column agent automation. An org's board settings can flag a column with + * `automation: { enabled, agentId }`; when a task ENTERS that column (a human + * drag, an edit, a create straight into it, or a run-driven advance) the + * configured agent is enqueued on the task — the ai-services-panel + * "auto-execute / auto-QA on column entry" pattern. + * + * The `automation_column_id` stamp on the item is the re-trigger guard: the + * agent's own run bouncing the task (QA reopens it → finishes → In Review + * again) never re-fires the same column, while a human moving the card away + * and back deliberately re-arms it (the update tool clears the stamp on human + * moves). Best-effort throughout — automation must never fail the write that + * moved the card. + */ + +import type { StudioContext } from "@/core/studio-context"; +import type { TaskBoardItem, TaskBoardItemStatus } from "@/storage/types"; +import { + columnForItem, + resolveBoardColumns, + shouldTriggerColumnAutomation, +} from "@decocms/shared/task-board-columns"; +import { enqueueAgentForTask } from "./enqueue-super-agent"; + +/** + * React to a task landing in a (possibly new) column. `previous` is the + * task's placement before this write (null for a create). No-op when the org + * has no automation on the target column, the task didn't actually change + * column, the guard stamp is set, or a linked run is active. + */ +export async function reactToColumnEntry( + ctx: StudioContext, + item: TaskBoardItem, + previous: { status: TaskBoardItemStatus; columnId: string | null } | null, +): Promise { + try { + const settings = await ctx.storage.organizationSettings.get( + item.organizationId, + ); + const columns = resolveBoardColumns(settings?.task_board?.columns); + const column = columnForItem(item, columns); + const previousColumnId = previous + ? columnForItem(previous, columns).id + : null; + if ( + !shouldTriggerColumnAutomation({ + column, + previousColumnId, + automationColumnId: item.automationColumnId, + threads: item.threads, + }) + ) { + return; + } + + // Stamp the guard BEFORE enqueueing so a re-entrant advance (the run's + // own lifecycle moving the card) can never double-fire this column. + await ctx.storage.taskBoard.update( + item.id, + item.organizationId, + { automationColumnId: column.id }, + item.updatedBy, + ); + await enqueueAgentForTask(ctx, item, column.automation?.agentId ?? null); + } catch (err) { + console.error("[task-board] column automation failed", err); + } +} diff --git a/apps/api/src/tools/task-board/comments.ts b/apps/api/src/tools/task-board/comments.ts new file mode 100644 index 0000000000..a795d1fe21 --- /dev/null +++ b/apps/api/src/tools/task-board/comments.ts @@ -0,0 +1,217 @@ +/** + * Task comments — a lightweight discussion stream on a task board item, with + * one level of replies (`parentId`) and optional image/file attachments + * carried inline as base64 (decoded and stored with the comment). + */ + +import { z } from "zod"; +import { defineTool } from "@/core/define-tool"; +import { getUserId, requireAuth } from "@/core/studio-context"; +import type { StudioContext } from "@/core/studio-context"; +import { + MAX_ATTACHMENT_BYTES, + TaskBoardAttachmentUploadSchema, + TaskBoardCommentSchema, +} from "./schema"; + +function requireOrgId(ctx: StudioContext): string { + const organizationId = ctx.organization?.id; + if (!organizationId) { + throw new Error( + "Organization ID required (no active organization in context)", + ); + } + return organizationId; +} + +async function requireTask( + ctx: StudioContext, + taskBoardItemId: string, + organizationId: string, +): Promise { + const task = await ctx.storage.taskBoard.getById( + taskBoardItemId, + organizationId, + ); + if (!task) throw new Error(`Task board item not found: ${taskBoardItemId}`); +} + +/** Decode a base64 upload, enforcing the size cap post-decode. */ +export function decodeAttachmentUpload(upload: { + filename: string; + dataBase64: string; +}): Uint8Array { + const data = Uint8Array.from(Buffer.from(upload.dataBase64, "base64")); + if (data.byteLength === 0) { + throw new Error(`Attachment "${upload.filename}" is empty`); + } + if (data.byteLength > MAX_ATTACHMENT_BYTES) { + throw new Error( + `Attachment "${upload.filename}" exceeds the ${Math.floor(MAX_ATTACHMENT_BYTES / (1024 * 1024))}MB limit`, + ); + } + return data; +} + +export const TASK_BOARD_COMMENT_CREATE = defineTool({ + name: "TASK_BOARD_COMMENT_CREATE", + description: + "Add a comment to a task board item, optionally as a reply to another comment and with image/file attachments.", + annotations: { + title: "Create Task Comment", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + inputSchema: z.object({ + taskBoardItemId: z.string(), + /** Reply target — must be a top-level comment on the same task. */ + parentId: z.string().nullable().optional(), + body: z.string().min(1).max(10_000), + attachments: z.array(TaskBoardAttachmentUploadSchema).max(5).optional(), + }), + outputSchema: z.object({ comment: TaskBoardCommentSchema }), + handler: async (input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + const organizationId = requireOrgId(ctx); + await requireTask(ctx, input.taskBoardItemId, organizationId); + + if (input.parentId) { + const parent = await ctx.storage.taskBoard.getCommentById( + input.parentId, + organizationId, + ); + if (!parent || parent.taskBoardItemId !== input.taskBoardItemId) { + throw new Error("Reply target not found on this task"); + } + if (parent.parentId) { + throw new Error( + "Replies are one level deep — reply to the thread's top comment", + ); + } + } + + // Decode (and size-check) every upload BEFORE any write, so a bad file + // can't leave a comment with half its attachments. + const uploads = (input.attachments ?? []).map((a) => ({ + ...a, + data: decodeAttachmentUpload(a), + })); + + const comment = await ctx.storage.taskBoard.createComment({ + organizationId, + taskBoardItemId: input.taskBoardItemId, + parentId: input.parentId ?? null, + body: input.body, + by: getUserId(ctx)!, + }); + for (const upload of uploads) { + comment.attachments.push( + await ctx.storage.taskBoard.addAttachment({ + organizationId, + taskBoardItemId: input.taskBoardItemId, + commentId: comment.id, + filename: upload.filename, + mimeType: upload.mimeType, + data: upload.data, + by: getUserId(ctx)!, + }), + ); + } + + return { comment }; + }, +}); + +export const TASK_BOARD_COMMENT_LIST = defineTool({ + name: "TASK_BOARD_COMMENT_LIST", + description: + "List the comments on a task board item (oldest first, replies included flat via parentId).", + annotations: { + title: "List Task Comments", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: z.object({ taskBoardItemId: z.string() }), + outputSchema: z.object({ comments: z.array(TaskBoardCommentSchema) }), + handler: async (input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + const organizationId = requireOrgId(ctx); + const comments = await ctx.storage.taskBoard.listComments( + input.taskBoardItemId, + organizationId, + ); + return { comments }; + }, +}); + +export const TASK_BOARD_COMMENT_UPDATE = defineTool({ + name: "TASK_BOARD_COMMENT_UPDATE", + description: "Edit the body of a task comment you authored.", + annotations: { + title: "Update Task Comment", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: z.object({ + id: z.string(), + body: z.string().min(1).max(10_000), + }), + outputSchema: z.object({ comment: TaskBoardCommentSchema }), + handler: async (input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + const organizationId = requireOrgId(ctx); + const existing = await ctx.storage.taskBoard.getCommentById( + input.id, + organizationId, + ); + if (!existing) throw new Error(`Comment not found: ${input.id}`); + if (existing.createdBy !== getUserId(ctx)) { + throw new Error("Only the comment's author can edit it"); + } + const comment = await ctx.storage.taskBoard.updateComment( + input.id, + organizationId, + input.body, + ); + return { comment }; + }, +}); + +export const TASK_BOARD_COMMENT_DELETE = defineTool({ + name: "TASK_BOARD_COMMENT_DELETE", + description: + "Delete a task comment you authored (its replies and attachments go with it).", + annotations: { + title: "Delete Task Comment", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: z.object({ id: z.string() }), + outputSchema: z.object({ success: z.boolean() }), + handler: async (input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + const organizationId = requireOrgId(ctx); + const existing = await ctx.storage.taskBoard.getCommentById( + input.id, + organizationId, + ); + if (!existing) return { success: true }; + if (existing.createdBy !== getUserId(ctx)) { + throw new Error("Only the comment's author can delete it"); + } + await ctx.storage.taskBoard.deleteComment(input.id, organizationId); + return { success: true }; + }, +}); diff --git a/apps/api/src/tools/task-board/create.ts b/apps/api/src/tools/task-board/create.ts index a358718bca..f4cb040346 100644 --- a/apps/api/src/tools/task-board/create.ts +++ b/apps/api/src/tools/task-board/create.ts @@ -9,6 +9,8 @@ import { } from "./schema"; import { assertValidAssignee } from "./validate-assignee"; import { reactToSuperAgentDelegation } from "./enqueue-super-agent"; +import { reactToColumnEntry } from "./column-automation"; +import { recordTaskActivity } from "./activity"; import { emitTaskBoardUpdated } from "./run-reactions"; export const TASK_BOARD_ITEM_CREATE = defineTool({ @@ -28,8 +30,18 @@ export const TASK_BOARD_ITEM_CREATE = defineTool({ priority: TaskBoardItemPrioritySchema.optional(), assigneeId: z.string().nullable().optional(), dueDate: z.string().datetime().nullable().optional(), + /** Custom-column placement (must belong to the org's board settings and + * match `status`'s stage — pass both together). */ + columnId: z.string().nullable().optional(), + tags: z.array(z.string().min(1).max(60)).max(20).optional(), + sprintId: z.string().nullable().optional(), + }), + outputSchema: z.object({ + item: TaskBoardItemSchema, + /** Present when the task was delegated to the Super Agent but its run + * couldn't start (e.g. no AI model configured) — surfaced to the user. */ + superAgentError: z.string().optional(), }), - outputSchema: z.object({ item: TaskBoardItemSchema }), handler: async (input, ctx) => { requireAuth(ctx); await ctx.access.check(); @@ -57,13 +69,27 @@ export const TASK_BOARD_ITEM_CREATE = defineTool({ assigneeId: input.assigneeId ?? null, assignedBy: input.assigneeId ? getUserId(ctx)! : null, dueDate: input.dueDate ?? null, + columnId: delegatedToSuperAgent ? null : input.columnId, + tags: input.tags, + sprintId: input.sprintId ?? null, by: getUserId(ctx)!, }); + await recordTaskActivity(ctx, { + organizationId, + taskBoardItemId: item.id, + kind: "created", + actorId: getUserId(ctx)!, + }); + // Broadcast the new card so every open board adds it live, no polling. emitTaskBoardUpdated(organizationId, item); - await reactToSuperAgentDelegation(ctx, item); + const superAgentError = await reactToSuperAgentDelegation(ctx, item); + // A task created straight into an automated column enqueues its agent. + if (!delegatedToSuperAgent) { + await reactToColumnEntry(ctx, item, null); + } - return { item }; + return { item, superAgentError: superAgentError ?? undefined }; }, }); diff --git a/apps/api/src/tools/task-board/enqueue-super-agent.ts b/apps/api/src/tools/task-board/enqueue-super-agent.ts index faecfc6cfe..f0e90ac0da 100644 --- a/apps/api/src/tools/task-board/enqueue-super-agent.ts +++ b/apps/api/src/tools/task-board/enqueue-super-agent.ts @@ -11,42 +11,57 @@ import { SUPER_AGENT_ASSIGNEE_ID } from "@decocms/shared/task-board"; * enqueue the run. No-op for any other assignee, so callers that already know * the write delegated (create) and callers that gate on the transition * (update) share one body. The SSE broadcast is emitted by each write site - * itself (every create/update broadcasts, not just delegations). Enqueue is - * best-effort — the task is already persisted, so a dispatch failure (e.g. no - * model configured) must never fail the write that delegated it. + * itself (every create/update broadcasts, not just delegations). + * + * Enqueue never fails the write (the task is already persisted), but the + * failure is NOT swallowed silently: the reason (e.g. "no model configured for + * tier smart") is returned so the tool can hand it back and the UI can tell the + * user why the run didn't start, instead of leaving a task assigned-but-idle. + * Returns null when there's nothing to report (not delegated, or started fine). */ export async function reactToSuperAgentDelegation( ctx: StudioContext, item: TaskBoardItem, -): Promise { - if (item.assigneeId !== SUPER_AGENT_ASSIGNEE_ID) return; - await enqueueSuperAgentForTask(ctx, item).catch((err) => { +): Promise { + if (item.assigneeId !== SUPER_AGENT_ASSIGNEE_ID) return null; + try { + await enqueueAgentForTask(ctx, item); + return null; + } catch (err) { console.error("[task-board] Super Agent enqueue failed", err); - }); + return err instanceof Error + ? err.message + : "Failed to start the Super Agent"; + } } /** - * Enqueue a Super Agent run for a task delegated to it. Creates a fresh thread - * seeded with the task in context, then dispatches the org's Super Agent (the - * well-known Decopilot agent) on it via the durable thread-gate queue. + * Enqueue an agent run for a task. Creates a fresh thread seeded with the task + * in context, then dispatches the agent on it via the durable thread-gate + * queue. `agentVirtualMcpId` selects the agent (a column automation's + * configured agent); omitted/null = the org's Super Agent (the well-known + * Decopilot agent) — the assignee-delegation path. * * ponytail: deliberately the simplest thing that runs the agent on the task — * a single text message, smart tier, no tool allowlist. Iterate on the prompt, * model, and metadata from here. */ -async function enqueueSuperAgentForTask( +export async function enqueueAgentForTask( ctx: StudioContext, task: TaskBoardItem, + agentVirtualMcpId?: string | null, ): Promise { const organizationId = task.organizationId; const userId = task.assignedBy ?? task.createdBy; const model = await resolveTier(ctx, "smart"); - const agentId = getDecopilotId(organizationId); + const agentId = agentVirtualMcpId ?? getDecopilotId(organizationId); const thread = await ctx.storage.threads.create({ organization_id: organizationId, - title: `Super Agent: ${task.title}`, + title: agentVirtualMcpId + ? `Agent: ${task.title}` + : `Super Agent: ${task.title}`, status: "in_progress", virtual_mcp_id: agentId, // Consume/terminal writer skips v1 threads — pin v2 or the run never completes. diff --git a/apps/api/src/tools/task-board/index.ts b/apps/api/src/tools/task-board/index.ts index 95a0a98083..0371fc7385 100644 --- a/apps/api/src/tools/task-board/index.ts +++ b/apps/api/src/tools/task-board/index.ts @@ -3,3 +3,26 @@ export { TASK_BOARD_ITEM_LIST } from "./list"; export { TASK_BOARD_ITEM_UPDATE } from "./update"; export { TASK_BOARD_ITEM_DELETE } from "./delete"; export { TASK_BOARD_ITEM_PRS_GET } from "./prs-get"; +export { + TASK_BOARD_COMMENT_CREATE, + TASK_BOARD_COMMENT_LIST, + TASK_BOARD_COMMENT_UPDATE, + TASK_BOARD_COMMENT_DELETE, +} from "./comments"; +export { + TASK_BOARD_ATTACHMENT_ADD, + TASK_BOARD_ATTACHMENT_LIST, + TASK_BOARD_ATTACHMENT_DELETE, +} from "./attachments"; +export { + TASK_BOARD_SPRINT_CREATE, + TASK_BOARD_SPRINT_UPDATE, + TASK_BOARD_SPRINT_DELETE, + TASK_BOARD_SPRINT_LIST, +} from "./sprints"; +export { + TASK_BOARD_RELEASE_CREATE, + TASK_BOARD_RELEASE_LIST, + TASK_BOARD_RELEASE_DELETE, +} from "./releases"; +export { TASK_BOARD_ACTIVITY_LIST } from "./activity"; diff --git a/apps/api/src/tools/task-board/releases.ts b/apps/api/src/tools/task-board/releases.ts new file mode 100644 index 0000000000..50f5ad904f --- /dev/null +++ b/apps/api/src/tools/task-board/releases.ts @@ -0,0 +1,100 @@ +/** + * Releases — optional packaging of finished tasks behind the org's + * `releasesEnabled` board setting. Creating a release stamps the selected + * tasks with its id; deleting one unstamps them. + */ + +import { z } from "zod"; +import { defineTool } from "@/core/define-tool"; +import { getUserId, requireAuth } from "@/core/studio-context"; +import { TaskBoardReleaseSchema } from "./schema"; + +export const TASK_BOARD_RELEASE_CREATE = defineTool({ + name: "TASK_BOARD_RELEASE_CREATE", + description: + "Create a release from a set of task board items (stamps each task with the release).", + annotations: { + title: "Create Release", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + inputSchema: z.object({ + title: z.string().min(1).max(200), + notes: z.string().max(20_000).nullable().optional(), + taskIds: z.array(z.string()).min(1).max(200), + }), + outputSchema: z.object({ release: TaskBoardReleaseSchema }), + handler: async (input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + const organizationId = ctx.organization?.id; + if (!organizationId) { + throw new Error( + "Organization ID required (no active organization in context)", + ); + } + const release = await ctx.storage.taskBoard.createRelease({ + organizationId, + title: input.title, + notes: input.notes ?? null, + taskIds: input.taskIds, + by: getUserId(ctx)!, + }); + return { release }; + }, +}); + +export const TASK_BOARD_RELEASE_LIST = defineTool({ + name: "TASK_BOARD_RELEASE_LIST", + description: "List the organization's releases (most recent first).", + annotations: { + title: "List Releases", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: z.object({}), + outputSchema: z.object({ releases: z.array(TaskBoardReleaseSchema) }), + handler: async (_input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + const organizationId = ctx.organization?.id; + if (!organizationId) { + throw new Error( + "Organization ID required (no active organization in context)", + ); + } + const releases = await ctx.storage.taskBoard.listReleases(organizationId); + return { releases }; + }, +}); + +export const TASK_BOARD_RELEASE_DELETE = defineTool({ + name: "TASK_BOARD_RELEASE_DELETE", + description: + "Delete a release. Its tasks are kept and lose the release stamp.", + annotations: { + title: "Delete Release", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: z.object({ id: z.string() }), + outputSchema: z.object({ success: z.boolean() }), + handler: async (input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + const organizationId = ctx.organization?.id; + if (!organizationId) { + throw new Error( + "Organization ID required (no active organization in context)", + ); + } + await ctx.storage.taskBoard.deleteRelease(input.id, organizationId); + return { success: true }; + }, +}); diff --git a/apps/api/src/tools/task-board/run-reactions.ts b/apps/api/src/tools/task-board/run-reactions.ts index 5c611e97d7..392d24a8a6 100644 --- a/apps/api/src/tools/task-board/run-reactions.ts +++ b/apps/api/src/tools/task-board/run-reactions.ts @@ -21,6 +21,7 @@ import type { StudioContext } from "@/core/studio-context"; import { extractPrFromValue } from "./pr-extract"; +import { reactToColumnEntry } from "./column-automation"; import { sseHub } from "@/event-bus/sse-hub"; import { TASK_BOARD_ITEM_DELETED_EVENT, @@ -29,13 +30,19 @@ import { import type { TaskBoardStorage } from "@/storage/task-board"; import type { TaskBoardItem, TaskBoardItemStatus } from "@/storage/types"; -/** Board lane order — a transition only moves a card forward, never back. */ +/** Canonical stage order — a run-driven transition only moves forward, never + * back. The tail stages (qa/ready_for_release/deploy) sit between review and + * done; nothing auto-advances into them (they're human/sync-driven), but the + * ranks keep a later human move from being clobbered by a late run event. */ const RANK: Record = { triage: 0, todo: 1, in_progress: 2, in_review: 3, - done: 4, + qa: 4, + ready_for_release: 5, + deploy: 6, + done: 7, }; /** Push a task board item change to every SSE listener on its org. */ @@ -129,7 +136,25 @@ export async function advanceTaskBoardForRun( { status }, ctx.auth?.user?.id ?? current.updatedBy, ); + // Timeline entry for the agent-driven move (actor "system"). Best-effort. + await ctx.storage.taskBoard + .recordActivity({ + organizationId: orgId, + taskBoardItemId: itemId, + kind: "status_changed", + actorId: "system", + data: { from: current.status, to: status }, + }) + .catch((err) => + console.error("[task-board] activity log write failed", err), + ); emitTaskBoardUpdated(orgId, item); + // A run-driven advance can land in an automated column too (e.g. a QA + // agent on In Review) — the automation stamp guards against loops. + await reactToColumnEntry(ctx, item, { + status: current.status, + columnId: current.columnId, + }); } } catch (err) { console.error("[task-board] run transition failed", err); diff --git a/apps/api/src/tools/task-board/schema.ts b/apps/api/src/tools/task-board/schema.ts index 2809093421..2512a80660 100644 --- a/apps/api/src/tools/task-board/schema.ts +++ b/apps/api/src/tools/task-board/schema.ts @@ -7,6 +7,9 @@ export const TaskBoardItemStatusSchema = z.enum([ "todo", "in_progress", "in_review", + "qa", + "ready_for_release", + "deploy", "done", ]); @@ -61,6 +64,18 @@ export const TaskBoardItemSchema = z.object({ dueDate: z.string().datetime().nullable(), // Manual drag-to-reorder position within a lane, ascending. sortOrder: z.number(), + /** Per-org sequential number — the task's short key. Null only for a legacy + * row not yet assigned one. */ + seq: z.number().nullable(), + /** Custom-column placement; null = derive from `status` (see org board + * settings). Always null on the default board. */ + columnId: z.string().nullable(), + tags: z.array(z.string()), + sprintId: z.string().nullable(), + releaseId: z.string().nullable(), + /** External identity from an importing connector (e.g. `jira:PROJ-123`); + * read-only, lets a sync correlate this task back to its source. */ + externalKey: z.string().nullable(), // Agent threads linked to this task (many-to-many), most-recent first. threads: z.array(TaskBoardItemThreadSchema), createdBy: z.string(), @@ -68,3 +83,79 @@ export const TaskBoardItemSchema = z.object({ updatedBy: z.string(), updatedAt: z.string().datetime(), }); + +export const TaskBoardAttachmentMetaSchema = z.object({ + id: z.string(), + taskBoardItemId: z.string(), + commentId: z.string().nullable(), + filename: z.string(), + mimeType: z.string(), + size: z.number(), + createdBy: z.string(), + createdAt: z.string().datetime(), +}); + +export const TaskBoardCommentSchema = z.object({ + id: z.string(), + taskBoardItemId: z.string(), + /** One level of replies — a reply's parentId is a top-level comment id. */ + parentId: z.string().nullable(), + body: z.string(), + attachments: z.array(TaskBoardAttachmentMetaSchema), + createdBy: z.string(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); + +export const TaskBoardSprintStateSchema = z.enum([ + "planned", + "active", + "closed", +]); + +export const TaskBoardSprintSchema = z.object({ + id: z.string(), + name: z.string(), + state: TaskBoardSprintStateSchema, + startDate: z.string().datetime().nullable(), + endDate: z.string().datetime().nullable(), + createdBy: z.string(), + createdAt: z.string().datetime(), +}); + +export const TaskBoardReleaseSchema = z.object({ + id: z.string(), + title: z.string(), + notes: z.string().nullable(), + createdBy: z.string(), + createdAt: z.string().datetime(), +}); + +const TaskBoardActivityKindSchema = z.enum([ + "created", + "status_changed", + "assignee_changed", + "sprint_changed", +]); + +export const TaskBoardActivitySchema = z.object({ + id: z.string(), + taskBoardItemId: z.string(), + kind: TaskBoardActivityKindSchema, + /** User id, or a sentinel ("system" / "super-agent"). */ + actorId: z.string().nullable(), + /** Event payload, e.g. { from, to } for a status/assignee/sprint change. */ + data: z.record(z.string(), z.unknown()), + createdAt: z.string().datetime(), +}); + +/** Inline upload payload for a new attachment. Size is capped post-decode. */ +export const TaskBoardAttachmentUploadSchema = z.object({ + filename: z.string().min(1).max(255), + mimeType: z.string().min(1).max(255), + /** Base64-encoded file bytes (no data-URL prefix). */ + dataBase64: z.string().min(1), +}); + +/** 10MB per file — matches the chat attach cap. */ +export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024; diff --git a/apps/api/src/tools/task-board/sprints.ts b/apps/api/src/tools/task-board/sprints.ts new file mode 100644 index 0000000000..eecf041bcc --- /dev/null +++ b/apps/api/src/tools/task-board/sprints.ts @@ -0,0 +1,144 @@ +/** + * Sprints — optional planning cycles behind the org's `sprintsEnabled` board + * setting. Tasks reference a sprint via `sprintId`; deleting a sprint returns + * its tasks to the backlog (no sprint). + */ + +import { z } from "zod"; +import { defineTool } from "@/core/define-tool"; +import { getUserId, requireAuth } from "@/core/studio-context"; +import { TaskBoardSprintSchema, TaskBoardSprintStateSchema } from "./schema"; + +export const TASK_BOARD_SPRINT_CREATE = defineTool({ + name: "TASK_BOARD_SPRINT_CREATE", + description: "Create a sprint for the organization's task board.", + annotations: { + title: "Create Sprint", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + inputSchema: z.object({ + name: z.string().min(1).max(120), + state: TaskBoardSprintStateSchema.optional(), + startDate: z.string().datetime().nullable().optional(), + endDate: z.string().datetime().nullable().optional(), + }), + outputSchema: z.object({ sprint: TaskBoardSprintSchema }), + handler: async (input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + const organizationId = ctx.organization?.id; + if (!organizationId) { + throw new Error( + "Organization ID required (no active organization in context)", + ); + } + const sprint = await ctx.storage.taskBoard.createSprint({ + organizationId, + name: input.name, + state: input.state, + startDate: input.startDate ?? null, + endDate: input.endDate ?? null, + by: getUserId(ctx)!, + }); + return { sprint }; + }, +}); + +export const TASK_BOARD_SPRINT_UPDATE = defineTool({ + name: "TASK_BOARD_SPRINT_UPDATE", + description: + "Update a sprint's name, state (planned/active/closed) or dates.", + annotations: { + title: "Update Sprint", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: z.object({ + id: z.string(), + name: z.string().min(1).max(120).optional(), + state: TaskBoardSprintStateSchema.optional(), + startDate: z.string().datetime().nullable().optional(), + endDate: z.string().datetime().nullable().optional(), + }), + outputSchema: z.object({ sprint: TaskBoardSprintSchema }), + handler: async (input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + const organizationId = ctx.organization?.id; + if (!organizationId) { + throw new Error( + "Organization ID required (no active organization in context)", + ); + } + const sprint = await ctx.storage.taskBoard.updateSprint( + input.id, + organizationId, + { + name: input.name, + state: input.state, + startDate: input.startDate, + endDate: input.endDate, + }, + ); + return { sprint }; + }, +}); + +export const TASK_BOARD_SPRINT_DELETE = defineTool({ + name: "TASK_BOARD_SPRINT_DELETE", + description: + "Delete a sprint. Its tasks are kept and returned to the backlog (no sprint).", + annotations: { + title: "Delete Sprint", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: z.object({ id: z.string() }), + outputSchema: z.object({ success: z.boolean() }), + handler: async (input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + const organizationId = ctx.organization?.id; + if (!organizationId) { + throw new Error( + "Organization ID required (no active organization in context)", + ); + } + await ctx.storage.taskBoard.deleteSprint(input.id, organizationId); + return { success: true }; + }, +}); + +export const TASK_BOARD_SPRINT_LIST = defineTool({ + name: "TASK_BOARD_SPRINT_LIST", + description: + "List the organization's sprints (active first, then planned, then closed).", + annotations: { + title: "List Sprints", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: z.object({}), + outputSchema: z.object({ sprints: z.array(TaskBoardSprintSchema) }), + handler: async (_input, ctx) => { + requireAuth(ctx); + await ctx.access.check(); + const organizationId = ctx.organization?.id; + if (!organizationId) { + throw new Error( + "Organization ID required (no active organization in context)", + ); + } + const sprints = await ctx.storage.taskBoard.listSprints(organizationId); + return { sprints }; + }, +}); diff --git a/apps/api/src/tools/task-board/update.ts b/apps/api/src/tools/task-board/update.ts index 809e223046..350f884012 100644 --- a/apps/api/src/tools/task-board/update.ts +++ b/apps/api/src/tools/task-board/update.ts @@ -10,6 +10,8 @@ import { } from "./schema"; import { assertValidAssignee } from "./validate-assignee"; import { reactToSuperAgentDelegation } from "./enqueue-super-agent"; +import { reactToColumnEntry } from "./column-automation"; +import { recordTaskActivity } from "./activity"; import { emitTaskBoardUpdated } from "./run-reactions"; export const TASK_BOARD_ITEM_UPDATE = defineTool({ @@ -33,10 +35,21 @@ export const TASK_BOARD_ITEM_UPDATE = defineTool({ dueDate: z.string().datetime().nullable().optional(), /** New drag-to-reorder position within its lane (ascending). */ sortOrder: z.number().optional(), + /** Custom-column placement (pass together with the column's `status` + * stage). Explicit null clears it (derive from status). */ + columnId: z.string().nullable().optional(), + tags: z.array(z.string().min(1).max(60)).max(20).optional(), + sprintId: z.string().nullable().optional(), + releaseId: z.string().nullable().optional(), /** Link an existing chat thread to this task (many-to-many, idempotent). */ linkThreadId: z.string().optional(), }), - outputSchema: z.object({ item: TaskBoardItemSchema }), + outputSchema: z.object({ + item: TaskBoardItemSchema, + /** Present when the task was delegated to the Super Agent but its run + * couldn't start (e.g. no AI model configured) — surfaced to the user. */ + superAgentError: z.string().optional(), + }), handler: async (input, ctx) => { requireAuth(ctx); await ctx.access.check(); @@ -82,13 +95,22 @@ export const TASK_BOARD_ITEM_UPDATE = defineTool({ input.priority !== undefined || input.assigneeId !== undefined || input.dueDate !== undefined || - input.sortOrder !== undefined; + input.sortOrder !== undefined || + input.columnId !== undefined || + input.tags !== undefined || + input.sprintId !== undefined || + input.releaseId !== undefined; - // Only enqueue on the transition INTO Super Agent, not on every later edit. - const previous = - input.assigneeId !== undefined - ? await ctx.storage.taskBoard.getById(input.id, organizationId) - : null; + // A column/status move needs the previous placement (automation fires on + // ENTERING a column); an assignee change needs the previous assignee + // (enqueue only on the transition INTO Super Agent). The full previous item + // is also diffed to log status/assignee/sprint changes to the activity + // timeline — so fetch it whenever any field changes. + const movesColumn = + input.status !== undefined || input.columnId !== undefined; + const previous = hasFieldUpdate + ? await ctx.storage.taskBoard.getById(input.id, organizationId) + : null; const assigneeChanged = input.assigneeId !== undefined && input.assigneeId !== (previous?.assigneeId ?? null); @@ -118,6 +140,13 @@ export const TASK_BOARD_ITEM_UPDATE = defineTool({ : undefined, dueDate: input.dueDate, sortOrder: input.sortOrder, + columnId: becameSuperAgent ? null : input.columnId, + tags: input.tags, + sprintId: input.sprintId, + releaseId: input.releaseId, + // A human move re-arms column automation for wherever the card + // lands (the stamp only survives run-driven bounces). + automationColumnId: movesColumn ? null : undefined, }, getUserId(ctx)!, ); @@ -130,16 +159,58 @@ export const TASK_BOARD_ITEM_UPDATE = defineTool({ item = fetched; } + // Log the meaningful changes to the activity timeline (status, assignee, + // sprint) by diffing against the pre-update item. Best-effort. + if (previous) { + const actorId = getUserId(ctx)!; + if (item.status !== previous.status) { + await recordTaskActivity(ctx, { + organizationId, + taskBoardItemId: item.id, + kind: "status_changed", + actorId, + data: { from: previous.status, to: item.status }, + }); + } + if (item.assigneeId !== previous.assigneeId) { + await recordTaskActivity(ctx, { + organizationId, + taskBoardItemId: item.id, + kind: "assignee_changed", + actorId, + data: { from: previous.assigneeId, to: item.assigneeId }, + }); + } + if (item.sprintId !== previous.sprintId) { + await recordTaskActivity(ctx, { + organizationId, + taskBoardItemId: item.id, + kind: "sprint_changed", + actorId, + data: { from: previous.sprintId, to: item.sprintId }, + }); + } + } + // Broadcast the delegation flip (assignee + forced To Do), or a new linked // thread, so every open board reflects it live. Plain edits already // round-trip through the mutation's optimistic patch + invalidate. + let superAgentError: string | null = null; if (becameSuperAgent) { emitTaskBoardUpdated(organizationId, item); - await reactToSuperAgentDelegation(ctx, item); + superAgentError = await reactToSuperAgentDelegation(ctx, item); } else if (input.linkThreadId) { emitTaskBoardUpdated(organizationId, item); } - return { item }; + // A human column/status move may land the task in an automated column. + if (!becameSuperAgent && movesColumn && previous) { + await reactToColumnEntry(ctx, item, { + status: previous.status, + columnId: previous.columnId, + }); + } + + return { item, superAgentError: superAgentError ?? undefined }; }, }); diff --git a/apps/web/src/hooks/use-organization-settings.ts b/apps/web/src/hooks/use-organization-settings.ts index eb184e4022..6810fc0387 100644 --- a/apps/web/src/hooks/use-organization-settings.ts +++ b/apps/web/src/hooks/use-organization-settings.ts @@ -17,6 +17,11 @@ import type { OrgFlags, SimpleModeTier, } from "@decocms/shared/organization/schema"; +export type { + TaskBoardSettingsConfig, + TaskBoardColumnConfig, +} from "@decocms/shared/organization/schema"; +import type { TaskBoardSettingsConfig } from "@decocms/shared/organization/schema"; export interface ModelSlot { keyId: string; @@ -46,6 +51,7 @@ export interface OrganizationSettings { default_home_agents: DefaultHomeAgentsConfig | null; flags: OrgFlags | null; main_agent_id: string | null; + task_board: TaskBoardSettingsConfig | null; createdAt?: string; updatedAt?: string; } @@ -59,6 +65,7 @@ const EMPTY_SETTINGS: OrganizationSettings = { default_home_agents: null, flags: null, main_agent_id: null, + task_board: null, }; const EMPTY_SIMPLE_MODE: SimpleModeConfig = { @@ -143,6 +150,7 @@ type OrgSettingsUpdateInput = Partial< | "default_home_agents" | "flags" | "main_agent_id" + | "task_board" > >; @@ -250,6 +258,27 @@ export function useOrgFlag(flag: keyof OrgFlags): boolean { return data ?? false; } +/** The org's task board settings (null = the default simple board). */ +export function useTaskBoardSettings(): TaskBoardSettingsConfig | null { + const { data } = useOrganizationSettings((s) => s.task_board); + return data ?? null; +} + +export function useUpdateTaskBoardSettings() { + const mutation = useUpdateOrganizationSettings(); + return { + ...mutation, + mutate: ( + config: TaskBoardSettingsConfig, + options?: OrgSettingsMutateOptions, + ) => mutation.mutate({ task_board: config }, options), + mutateAsync: ( + config: TaskBoardSettingsConfig, + options?: OrgSettingsMutateOptions, + ) => mutation.mutateAsync({ task_board: config }, options), + }; +} + export function useRegistryConfig(): RegistryConfig | null { const { data } = useOrganizationSettings((s) => s.registry_config); return data ?? null; diff --git a/apps/web/src/hooks/use-task-board-activity.ts b/apps/web/src/hooks/use-task-board-activity.ts new file mode 100644 index 0000000000..0d62dc4436 --- /dev/null +++ b/apps/web/src/hooks/use-task-board-activity.ts @@ -0,0 +1,25 @@ +/** A task's change timeline (created, status/assignee/sprint changes). */ + +import { useProjectContext } from "@/sdk"; +import { useQuery } from "@tanstack/react-query"; +import { KEYS } from "@/lib/query-keys"; +import { useStudioTools } from "@/lib/studio-tools"; +import type { StudioToolOutput as ToolOutput } from "@decocms/shared/tools/tool-io"; + +export type TaskBoardActivity = + ToolOutput<"TASK_BOARD_ACTIVITY_LIST">["activity"][number]; + +export function useTaskBoardActivity(itemId: string | undefined) { + const { locator } = useProjectContext(); + const studio = useStudioTools(); + return useQuery({ + queryKey: KEYS.taskBoardActivity(locator, itemId ?? ""), + enabled: !!itemId, + queryFn: async () => + ( + await studio.call("TASK_BOARD_ACTIVITY_LIST", { + taskBoardItemId: itemId!, + }) + ).activity, + }); +} diff --git a/apps/web/src/hooks/use-task-board-comments.ts b/apps/web/src/hooks/use-task-board-comments.ts new file mode 100644 index 0000000000..7c3b4917c6 --- /dev/null +++ b/apps/web/src/hooks/use-task-board-comments.ts @@ -0,0 +1,114 @@ +/** + * Comments + attachments for one task board item. The dialog fetches these on + * open; mutations invalidate the per-task query (no SSE — comments are a + * dialog-scoped surface). + */ + +import { useProjectContext } from "@/sdk"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { KEYS } from "@/lib/query-keys"; +import { useStudioTools } from "@/lib/studio-tools"; +import type { + StudioToolInput as ToolInput, + StudioToolOutput as ToolOutput, +} from "@decocms/shared/tools/tool-io"; + +export type TaskBoardComment = + ToolOutput<"TASK_BOARD_COMMENT_LIST">["comments"][number]; +export type TaskBoardAttachment = + ToolOutput<"TASK_BOARD_ATTACHMENT_LIST">["attachments"][number]; + +/** URL serving an attachment's bytes (org-scoped, auth via session cookie). */ +export function taskBoardAttachmentUrl( + orgSlug: string, + attachmentId: string, +): string { + return `/api/${encodeURIComponent(orgSlug)}/task-board/attachments/${attachmentId}`; +} + +export function useTaskBoardComments(itemId: string | undefined) { + const { locator } = useProjectContext(); + const studio = useStudioTools(); + return useQuery({ + queryKey: KEYS.taskBoardComments(locator, itemId ?? ""), + enabled: !!itemId, + queryFn: async () => + ( + await studio.call("TASK_BOARD_COMMENT_LIST", { + taskBoardItemId: itemId!, + }) + ).comments, + }); +} + +export function useTaskBoardAttachments(itemId: string | undefined) { + const { locator } = useProjectContext(); + const studio = useStudioTools(); + return useQuery({ + queryKey: KEYS.taskBoardAttachments(locator, itemId ?? ""), + enabled: !!itemId, + queryFn: async () => + ( + await studio.call("TASK_BOARD_ATTACHMENT_LIST", { + taskBoardItemId: itemId!, + }) + ).attachments, + }); +} + +export function useTaskBoardCommentActions(itemId: string | undefined) { + const { locator } = useProjectContext(); + const studio = useStudioTools(); + const queryClient = useQueryClient(); + const invalidate = () => { + if (!itemId) return; + queryClient.invalidateQueries({ + queryKey: KEYS.taskBoardComments(locator, itemId), + }); + queryClient.invalidateQueries({ + queryKey: KEYS.taskBoardAttachments(locator, itemId), + }); + }; + + const create = useMutation({ + mutationFn: ( + input: Omit, "taskBoardItemId">, + ) => + studio.call("TASK_BOARD_COMMENT_CREATE", { + ...input, + taskBoardItemId: itemId!, + }), + onSuccess: invalidate, + }); + + const update = useMutation({ + mutationFn: (input: { id: string; body: string }) => + studio.call("TASK_BOARD_COMMENT_UPDATE", input), + onSuccess: invalidate, + }); + + const remove = useMutation({ + mutationFn: (id: string) => + studio.call("TASK_BOARD_COMMENT_DELETE", { id }), + onSuccess: invalidate, + }); + + const addAttachment = useMutation({ + mutationFn: ( + input: Omit, "taskBoardItemId">, + ) => + studio.call("TASK_BOARD_ATTACHMENT_ADD", { + ...input, + taskBoardItemId: itemId!, + }), + onSuccess: invalidate, + }); + + const removeAttachment = useMutation({ + mutationFn: (id: string) => + studio.call("TASK_BOARD_ATTACHMENT_DELETE", { id }), + onSuccess: invalidate, + }); + + return { create, update, remove, addAttachment, removeAttachment }; +} diff --git a/apps/web/src/hooks/use-task-board-items.ts b/apps/web/src/hooks/use-task-board-items.ts index beccde0f25..0430ead031 100644 --- a/apps/web/src/hooks/use-task-board-items.ts +++ b/apps/web/src/hooks/use-task-board-items.ts @@ -1,5 +1,6 @@ import { useProjectContext } from "@/sdk"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; import { KEYS } from "@/lib/query-keys"; import { useStudioTools } from "@/lib/studio-tools"; import type { @@ -83,10 +84,20 @@ export function useTaskBoardItemActions() { const invalidate = () => queryClient.invalidateQueries({ queryKey }); + // The task was delegated to the Super Agent but its run couldn't start (most + // commonly no AI model configured) — tell the user why instead of leaving the + // card assigned-but-idle. + const toastSuperAgentError = (result: { superAgentError?: string }) => { + if (result.superAgentError) toast.error(result.superAgentError); + }; + const create = useMutation({ mutationFn: (input: ToolInput<"TASK_BOARD_ITEM_CREATE">) => studio.call("TASK_BOARD_ITEM_CREATE", input), - onSuccess: invalidate, + onSuccess: (result) => { + toastSuperAgentError(result); + invalidate(); + }, }); const update = useMutation({ @@ -106,6 +117,7 @@ export function useTaskBoardItemActions() { if (context?.previous) queryClient.setQueryData(queryKey, context.previous); }, + onSuccess: toastSuperAgentError, onSettled: invalidate, }); diff --git a/apps/web/src/hooks/use-task-board-releases.ts b/apps/web/src/hooks/use-task-board-releases.ts new file mode 100644 index 0000000000..3606d2f061 --- /dev/null +++ b/apps/web/src/hooks/use-task-board-releases.ts @@ -0,0 +1,53 @@ +/** Org task board releases — fetched only when the releases toggle is on. */ + +import { useProjectContext } from "@/sdk"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { KEYS } from "@/lib/query-keys"; +import { useStudioTools } from "@/lib/studio-tools"; +import type { + StudioToolInput as ToolInput, + StudioToolOutput as ToolOutput, +} from "@decocms/shared/tools/tool-io"; + +export type TaskBoardRelease = + ToolOutput<"TASK_BOARD_RELEASE_LIST">["releases"][number]; + +export function useTaskBoardReleases(enabled: boolean) { + const { locator } = useProjectContext(); + const studio = useStudioTools(); + const query = useQuery({ + queryKey: KEYS.taskBoardReleases(locator), + enabled, + queryFn: async () => + (await studio.call("TASK_BOARD_RELEASE_LIST", {})).releases, + }); + return { releases: query.data ?? [], isLoading: query.isLoading }; +} + +export function useTaskBoardReleaseActions() { + const { locator } = useProjectContext(); + const studio = useStudioTools(); + const queryClient = useQueryClient(); + const invalidate = () => { + queryClient.invalidateQueries({ + queryKey: KEYS.taskBoardReleases(locator), + }); + // Creating/deleting a release (un)stamps tasks server-side. + queryClient.invalidateQueries({ + queryKey: KEYS.taskBoardItems(locator), + }); + }; + + const create = useMutation({ + mutationFn: (input: ToolInput<"TASK_BOARD_RELEASE_CREATE">) => + studio.call("TASK_BOARD_RELEASE_CREATE", input), + onSuccess: invalidate, + }); + const remove = useMutation({ + mutationFn: (id: string) => + studio.call("TASK_BOARD_RELEASE_DELETE", { id }), + onSuccess: invalidate, + }); + + return { create, remove }; +} diff --git a/apps/web/src/i18n/en/settings.ts b/apps/web/src/i18n/en/settings.ts index 90306c992d..9ab61e0161 100644 --- a/apps/web/src/i18n/en/settings.ts +++ b/apps/web/src/i18n/en/settings.ts @@ -3,6 +3,7 @@ export const settings = { "settings.nav.organization": "Organization", "settings.nav.general": "General", "settings.nav.brandContext": "Brand Context", + "settings.nav.board": "Board", "settings.nav.aiProviders": "AI Providers", "settings.nav.secrets": "Secrets", "settings.nav.buckets": "Buckets", @@ -19,6 +20,31 @@ export const settings = { "settings.nav.account": "Account", "settings.nav.profile": "Profile & Preferences", "settings.nav.signOut": "Sign Out", + "settings.board.title": "Board", + "settings.board.saved": "Board settings saved", + "settings.board.saveFailed": "Failed to save board settings", + "settings.board.columnsTitle": "Columns", + "settings.board.columnsDescription": + "Rename, reorder or add board columns. Each column maps to a stage, which is what agent automation follows.", + "settings.board.resetColumns": "Reset to defaults", + "settings.board.saveColumns": "Save columns", + "settings.board.addColumn": "Add column", + "settings.board.stageLabel": "Stage:", + "settings.board.moveColumnUpAriaLabel": "Move column up", + "settings.board.moveColumnDownAriaLabel": "Move column down", + "settings.board.deleteColumnAriaLabel": "Delete column", + "settings.board.automationLabel": "Run agent on entry", + "settings.board.automationSuperAgent": "Super Agent", + "settings.board.automationAgentUnknown": "Unknown agent", + "settings.board.featuresTitle": "Features", + "settings.board.featuresDescription": + "Optional planning features. Off by default to keep the board simple.", + "settings.board.sprintsToggle": "Sprints", + "settings.board.sprintsToggleDescription": + "Plan work in weekly sprints and filter the board by sprint. Sprints are the calendar weeks — pick one per task, nothing to set up.", + "settings.board.releasesToggle": "Releases", + "settings.board.releasesToggleDescription": + "Group finished tasks into releases from the board.", "settings.profile.avatar": "Avatar", "settings.profile.displayName": "Display name", "settings.profile.displayNamePlaceholder": "Your name", diff --git a/apps/web/src/i18n/en/task-board.ts b/apps/web/src/i18n/en/task-board.ts index 67778ff698..2bee5905a2 100644 --- a/apps/web/src/i18n/en/task-board.ts +++ b/apps/web/src/i18n/en/task-board.ts @@ -21,6 +21,9 @@ export const taskBoard = { "taskBoard.config.statusDone": "Done", "taskBoard.config.statusInProgress": "In Progress", "taskBoard.config.statusInReview": "In Review", + "taskBoard.config.statusQa": "QA", + "taskBoard.config.statusReadyForRelease": "Ready for Release", + "taskBoard.config.statusDeploy": "Deploy", "taskBoard.config.statusTodo": "To Do", "taskBoard.taskBoard.assignedToSuperAgent": "Assigned to Super Agent", "taskBoard.taskBoard.assignedToSuperAgentBy": @@ -28,35 +31,85 @@ export const taskBoard = { "taskBoard.taskBoard.autoFix": "Auto-fix", "taskBoard.taskBoard.blockedBadgeTitle": "The agent is waiting for your input", + "taskBoard.taskBoard.cancelReleaseButton": "Cancel", "taskBoard.taskBoard.clearFilters": "Clear filters", + "taskBoard.taskBoard.columnAutomationHint": + "An agent runs automatically when a task enters this column", "taskBoard.taskBoard.connectGithubButton": "Connect GitHub", "taskBoard.taskBoard.connectGithubDescription": "Auto-fix hands the task to the Super Agent, which opens a pull request with the change. Connect GitHub so it can push and open PRs.", "taskBoard.taskBoard.connectGithubTitle": "Connect GitHub", + "taskBoard.taskBoard.createReleaseButton": "Create release", + "taskBoard.taskBoard.createReleaseDescription": + "Package the {count} selected tasks into a release.", + "taskBoard.taskBoard.createReleaseTitle": "Create release", + "taskBoard.taskBoard.deleteReleaseAriaLabel": "Delete release {title}", "taskBoard.taskBoard.layoutViewAriaLabel": "{label} view", "taskBoard.taskBoard.needsInput": "Needs input", "taskBoard.taskBoard.newTask": "New task", "taskBoard.taskBoard.newTaskInLaneAriaLabel": "New task in {lane}", "taskBoard.taskBoard.newTaskInLaneTitle": "New task in {lane}", + "taskBoard.taskBoard.noReleases": "No releases yet.", "taskBoard.taskBoard.noTasksMatch": "No tasks match these filters.", "taskBoard.taskBoard.noTasksYet": "No tasks yet. Start one with New task.", + "taskBoard.taskBoard.newReleaseButton": "New release", + "taskBoard.taskBoard.releaseNoTasks": "No tasks in this release.", + "taskBoard.taskBoard.releasesMenuLabel": "Releases", + "taskBoard.taskBoard.releaseNotesPlaceholder": "Release notes (optional)...", + "taskBoard.taskBoard.releaseSelectedCount": "{count} selected", + "taskBoard.taskBoard.releaseTitlePlaceholder": "Release title...", + "taskBoard.taskBoard.sprintAll": "All sprints", + "taskBoard.taskBoard.sprintBacklog": "Backlog (no sprint)", "taskBoard.taskBoard.tasksTitle": "Tasks", + "taskBoard.taskDialog.addLabelButton": "Add label", + "taskBoard.taskDialog.addLabelsPlaceholder": "Add labels...", + "taskBoard.taskDialog.addTagPlaceholder": "Add tag...", "taskBoard.taskDialog.assignButton": "Assign", + "taskBoard.taskDialog.createLabelOption": "Create {label}", + "taskBoard.taskDialog.noLabelsFound": "No labels found.", "taskBoard.taskDialog.assignToPlaceholder": "Assign to…", + "taskBoard.taskDialog.attachButton": "Attach", + "taskBoard.taskDialog.attachmentTooLarge": "{name} is larger than 10MB", + "taskBoard.taskDialog.attachmentUploadFailed": "Failed to upload {name}", + "taskBoard.taskDialog.attachmentsLabel": "Attachments", + "taskBoard.taskDialog.attachToCommentAriaLabel": + "Attach a file to the comment", + "taskBoard.taskDialog.cancelButton": "Cancel", + "taskBoard.taskDialog.cancelReplyAriaLabel": "Cancel reply", "taskBoard.taskDialog.clearDueDateAriaLabel": "Clear due date", "taskBoard.taskDialog.closeAriaLabel": "Close", + "taskBoard.taskDialog.commentPlaceholder": "Write a comment...", + "taskBoard.taskDialog.commentSendFailed": "Failed to send the comment", + "taskBoard.taskDialog.activityAssigned": "assigned to {name}", + "taskBoard.taskDialog.activityCreated": "created the task", + "taskBoard.taskDialog.activityDelegated": "delegated to Super Agent", + "taskBoard.taskDialog.activityLabel": "Activity", + "taskBoard.taskDialog.activityMovedFromTo": "moved from {from} to {to}", + "taskBoard.taskDialog.activityMovedTo": "moved to {to}", + "taskBoard.taskDialog.activitySprintMoved": "moved to {name}", + "taskBoard.taskDialog.activitySprintRemoved": "removed from sprint", + "taskBoard.taskDialog.activityUnassigned": "unassigned this", + "taskBoard.taskDialog.commentsLabel": "Comments", + "taskBoard.taskDialog.leaveReplyPlaceholder": "Leave a reply...", "taskBoard.taskDialog.linksLabel": "Links", - "taskBoard.taskDialog.openThreadHint": "Open", "taskBoard.taskDialog.openPreviewButton": "Edit", "taskBoard.taskDialog.openPreviewTitle": "Open the live preview for this branch", + "taskBoard.taskDialog.openThreadHint": "Open", "taskBoard.taskDialog.copyDescriptionAriaLabel": "Copy description", "taskBoard.taskDialog.createTaskButton": "Create task", + "taskBoard.taskDialog.deleteAttachmentAriaLabel": "Delete attachment {name}", + "taskBoard.taskDialog.deleteCommentAriaLabel": "Delete comment", "taskBoard.taskDialog.deleteTaskAriaLabel": "Delete task", "taskBoard.taskDialog.descriptionPlaceholder": "Describe a task for an agent...", + "taskBoard.taskDialog.downloadAttachmentAriaLabel": "Download {name}", "taskBoard.taskDialog.dueDateLabel": "Due date", + "taskBoard.taskDialog.editCommentAriaLabel": "Edit comment", "taskBoard.taskDialog.editTaskTitle": "Edit task", + "taskBoard.taskDialog.lightboxNextAriaLabel": "Next image", + "taskBoard.taskDialog.lightboxPrevAriaLabel": "Previous image", + "taskBoard.taskDialog.loadingLabel": "Loading…", "taskBoard.taskDialog.membersGroupHeading": "Members", "taskBoard.taskDialog.newChatButton": "New chat", "taskBoard.taskDialog.newTaskTitle": "New task", @@ -66,12 +119,24 @@ export const taskBoard = { "taskBoard.taskDialog.prStateMerged": "Merged", "taskBoard.taskDialog.prStateOpen": "Open", "taskBoard.taskDialog.propertiesLabel": "Properties", + "taskBoard.taskDialog.pullRequestsLabel": "Pull requests", + "taskBoard.taskDialog.removePendingFileAriaLabel": "Remove {name}", + "taskBoard.taskDialog.removeTagAriaLabel": "Remove tag {tag}", + "taskBoard.taskDialog.replyButton": "Reply", + "taskBoard.taskDialog.replyingTo": "Replying to {name}", "taskBoard.taskDialog.saveButton": "Save", + "taskBoard.taskDialog.sendCommentAriaLabel": "Send comment", "taskBoard.taskDialog.setPriorityButton": "Set priority", "taskBoard.taskDialog.someoneLabel": "someone", + "taskBoard.taskDialog.sprintLabel": "Sprint", + "taskBoard.taskDialog.sprintNone": "No sprint", + "taskBoard.taskDialog.sprintStateCurrent": "Current", + "taskBoard.taskDialog.sprintStatePrevious": "Previous", + "taskBoard.taskDialog.sprintStateUpcoming": "Upcoming", "taskBoard.taskDialog.startedByLabel": "started by", "taskBoard.taskDialog.superAgentDefaultName": "Super Agent", "taskBoard.taskDialog.superAgentLabel": "Super Agent", + "taskBoard.taskDialog.tagsLabel": "Tags", "taskBoard.taskDialog.taskTitlePlaceholder": "Task title...", "taskBoard.taskDialog.threadStatusCompleted": "Completed", "taskBoard.taskDialog.threadStatusError": "Error", @@ -100,4 +165,6 @@ export const taskBoard = { "taskBoard.taskFilters.filterDrawerTitle": "Filters", "taskBoard.taskFilters.priorityAnyPriority": "Any priority", "taskBoard.taskFilters.priorityLabel": "Priority", + "taskBoard.taskFilters.tagAnyTag": "Any tag", + "taskBoard.taskFilters.tagLabel": "Tag", } as const; diff --git a/apps/web/src/i18n/pt-br/settings.ts b/apps/web/src/i18n/pt-br/settings.ts index 3c7f3aec30..3ad5a77596 100644 --- a/apps/web/src/i18n/pt-br/settings.ts +++ b/apps/web/src/i18n/pt-br/settings.ts @@ -5,6 +5,7 @@ export const settings = { "settings.nav.organization": "Organização", "settings.nav.general": "Geral", "settings.nav.brandContext": "Contexto da marca", + "settings.nav.board": "Quadro", "settings.nav.aiProviders": "Provedores de IA", "settings.nav.secrets": "Segredos", "settings.nav.buckets": "Buckets", @@ -21,6 +22,31 @@ export const settings = { "settings.nav.account": "Conta", "settings.nav.profile": "Perfil e preferências", "settings.nav.signOut": "Sair", + "settings.board.title": "Quadro", + "settings.board.saved": "Configurações do quadro salvas", + "settings.board.saveFailed": "Falha ao salvar as configurações do quadro", + "settings.board.columnsTitle": "Colunas", + "settings.board.columnsDescription": + "Renomeie, reordene ou adicione colunas ao quadro. Cada coluna mapeia para uma etapa, que é o que a automação dos agentes segue.", + "settings.board.resetColumns": "Restaurar padrão", + "settings.board.saveColumns": "Salvar colunas", + "settings.board.addColumn": "Adicionar coluna", + "settings.board.stageLabel": "Etapa:", + "settings.board.moveColumnUpAriaLabel": "Mover coluna para cima", + "settings.board.moveColumnDownAriaLabel": "Mover coluna para baixo", + "settings.board.deleteColumnAriaLabel": "Deletar coluna", + "settings.board.automationLabel": "Rodar agente na entrada", + "settings.board.automationSuperAgent": "Super Agent", + "settings.board.automationAgentUnknown": "Agente desconhecido", + "settings.board.featuresTitle": "Recursos", + "settings.board.featuresDescription": + "Recursos opcionais de planejamento. Desligados por padrão para manter o quadro simples.", + "settings.board.sprintsToggle": "Sprints", + "settings.board.sprintsToggleDescription": + "Planeje o trabalho em sprints semanais e filtre o quadro por sprint. Sprints são as semanas do calendário — escolha uma por tarefa, sem configuração.", + "settings.board.releasesToggle": "Releases", + "settings.board.releasesToggleDescription": + "Agrupe tarefas concluídas em releases a partir do quadro.", "settings.profile.avatar": "Avatar", "settings.profile.displayName": "Nome de exibição", "settings.profile.displayNamePlaceholder": "Seu nome", diff --git a/apps/web/src/i18n/pt-br/task-board.ts b/apps/web/src/i18n/pt-br/task-board.ts index a125b2e751..d48f6ca369 100644 --- a/apps/web/src/i18n/pt-br/task-board.ts +++ b/apps/web/src/i18n/pt-br/task-board.ts @@ -24,6 +24,9 @@ export const taskBoard = { "taskBoard.config.statusDone": "Concluído", "taskBoard.config.statusInProgress": "Em Progresso", "taskBoard.config.statusInReview": "Em Revisão", + "taskBoard.config.statusQa": "QA", + "taskBoard.config.statusReadyForRelease": "Pronto para Release", + "taskBoard.config.statusDeploy": "Deploy", "taskBoard.config.statusTodo": "A Fazer", "taskBoard.taskBoard.assignedToSuperAgent": "Atribuído ao Super Agent", "taskBoard.taskBoard.assignedToSuperAgentBy": @@ -31,37 +34,88 @@ export const taskBoard = { "taskBoard.taskBoard.autoFix": "Auto-correção", "taskBoard.taskBoard.blockedBadgeTitle": "O agente está aguardando sua entrada", + "taskBoard.taskBoard.cancelReleaseButton": "Cancelar", "taskBoard.taskBoard.clearFilters": "Limpar filtros", + "taskBoard.taskBoard.columnAutomationHint": + "Um agente roda automaticamente quando uma tarefa entra nesta coluna", "taskBoard.taskBoard.connectGithubButton": "Conectar GitHub", "taskBoard.taskBoard.connectGithubDescription": "Auto-fix passa a tarefa para o Super Agent, que abre um pull request com a mudança. Conecte o GitHub para que ele possa fazer push e abrir PRs.", "taskBoard.taskBoard.connectGithubTitle": "Conectar GitHub", + "taskBoard.taskBoard.createReleaseButton": "Criar release", + "taskBoard.taskBoard.createReleaseDescription": + "Empacote as {count} tarefas selecionadas em uma release.", + "taskBoard.taskBoard.createReleaseTitle": "Criar release", + "taskBoard.taskBoard.deleteReleaseAriaLabel": "Deletar release {title}", "taskBoard.taskBoard.layoutViewAriaLabel": "visualização {label}", "taskBoard.taskBoard.needsInput": "Precisa de entrada", "taskBoard.taskBoard.newTask": "Nova tarefa", "taskBoard.taskBoard.newTaskInLaneAriaLabel": "Nova tarefa em {lane}", "taskBoard.taskBoard.newTaskInLaneTitle": "Nova tarefa em {lane}", + "taskBoard.taskBoard.noReleases": "Nenhuma release ainda.", "taskBoard.taskBoard.noTasksMatch": "Nenhuma tarefa corresponde a estes filtros.", "taskBoard.taskBoard.noTasksYet": "Nenhuma tarefa ainda. Comece uma com Nova tarefa.", + "taskBoard.taskBoard.newReleaseButton": "Nova release", + "taskBoard.taskBoard.releaseNoTasks": "Nenhuma tarefa nesta release.", + "taskBoard.taskBoard.releasesMenuLabel": "Releases", + "taskBoard.taskBoard.releaseNotesPlaceholder": + "Notas da release (opcional)...", + "taskBoard.taskBoard.releaseSelectedCount": "{count} selecionadas", + "taskBoard.taskBoard.releaseTitlePlaceholder": "Título da release...", + "taskBoard.taskBoard.sprintAll": "Todas as sprints", + "taskBoard.taskBoard.sprintBacklog": "Backlog (sem sprint)", "taskBoard.taskBoard.tasksTitle": "Tarefas", + "taskBoard.taskDialog.addLabelButton": "Adicionar label", + "taskBoard.taskDialog.addLabelsPlaceholder": "Adicionar labels...", + "taskBoard.taskDialog.addTagPlaceholder": "Adicionar tag...", "taskBoard.taskDialog.assignButton": "Atribuir", + "taskBoard.taskDialog.createLabelOption": "Criar {label}", + "taskBoard.taskDialog.noLabelsFound": "Nenhuma label encontrada.", "taskBoard.taskDialog.assignToPlaceholder": "Atribuir a…", + "taskBoard.taskDialog.attachButton": "Anexar", + "taskBoard.taskDialog.attachmentTooLarge": "{name} é maior que 10MB", + "taskBoard.taskDialog.attachmentUploadFailed": "Falha ao enviar {name}", + "taskBoard.taskDialog.attachmentsLabel": "Anexos", + "taskBoard.taskDialog.attachToCommentAriaLabel": + "Anexar um arquivo ao comentário", + "taskBoard.taskDialog.cancelButton": "Cancelar", + "taskBoard.taskDialog.cancelReplyAriaLabel": "Cancelar resposta", "taskBoard.taskDialog.clearDueDateAriaLabel": "Limpar data de vencimento", "taskBoard.taskDialog.closeAriaLabel": "Fechar", + "taskBoard.taskDialog.commentPlaceholder": "Escreva um comentário...", + "taskBoard.taskDialog.commentSendFailed": "Falha ao enviar o comentário", + "taskBoard.taskDialog.activityAssigned": "atribuiu para {name}", + "taskBoard.taskDialog.activityCreated": "criou a tarefa", + "taskBoard.taskDialog.activityDelegated": "delegou ao Super Agent", + "taskBoard.taskDialog.activityLabel": "Atividade", + "taskBoard.taskDialog.activityMovedFromTo": "moveu de {from} para {to}", + "taskBoard.taskDialog.activityMovedTo": "moveu para {to}", + "taskBoard.taskDialog.activitySprintMoved": "moveu para {name}", + "taskBoard.taskDialog.activitySprintRemoved": "removeu da sprint", + "taskBoard.taskDialog.activityUnassigned": "removeu a atribuição", + "taskBoard.taskDialog.commentsLabel": "Comentários", + "taskBoard.taskDialog.leaveReplyPlaceholder": "Deixe uma resposta...", "taskBoard.taskDialog.linksLabel": "Links", - "taskBoard.taskDialog.openThreadHint": "Abrir", "taskBoard.taskDialog.openPreviewButton": "Editar", "taskBoard.taskDialog.openPreviewTitle": "Abrir o preview ao vivo desta branch", + "taskBoard.taskDialog.openThreadHint": "Abrir", "taskBoard.taskDialog.copyDescriptionAriaLabel": "Copiar descrição", "taskBoard.taskDialog.createTaskButton": "Criar tarefa", + "taskBoard.taskDialog.deleteAttachmentAriaLabel": "Deletar anexo {name}", + "taskBoard.taskDialog.deleteCommentAriaLabel": "Deletar comentário", "taskBoard.taskDialog.deleteTaskAriaLabel": "Deletar tarefa", "taskBoard.taskDialog.descriptionPlaceholder": "Descreva uma tarefa para um agente...", + "taskBoard.taskDialog.downloadAttachmentAriaLabel": "Baixar {name}", "taskBoard.taskDialog.dueDateLabel": "Data de vencimento", + "taskBoard.taskDialog.editCommentAriaLabel": "Editar comentário", "taskBoard.taskDialog.editTaskTitle": "Editar tarefa", + "taskBoard.taskDialog.lightboxNextAriaLabel": "Próxima imagem", + "taskBoard.taskDialog.lightboxPrevAriaLabel": "Imagem anterior", + "taskBoard.taskDialog.loadingLabel": "Carregando…", "taskBoard.taskDialog.membersGroupHeading": "Membros", "taskBoard.taskDialog.newChatButton": "Novo chat", "taskBoard.taskDialog.newTaskTitle": "Nova tarefa", @@ -71,12 +125,24 @@ export const taskBoard = { "taskBoard.taskDialog.prStateMerged": "Mesclado", "taskBoard.taskDialog.prStateOpen": "Aberto", "taskBoard.taskDialog.propertiesLabel": "Propriedades", + "taskBoard.taskDialog.pullRequestsLabel": "Pull requests", + "taskBoard.taskDialog.removePendingFileAriaLabel": "Remover {name}", + "taskBoard.taskDialog.removeTagAriaLabel": "Remover tag {tag}", + "taskBoard.taskDialog.replyButton": "Responder", + "taskBoard.taskDialog.replyingTo": "Respondendo a {name}", "taskBoard.taskDialog.saveButton": "Salvar", + "taskBoard.taskDialog.sendCommentAriaLabel": "Enviar comentário", "taskBoard.taskDialog.setPriorityButton": "Definir prioridade", "taskBoard.taskDialog.someoneLabel": "alguém", + "taskBoard.taskDialog.sprintLabel": "Sprint", + "taskBoard.taskDialog.sprintNone": "Sem sprint", + "taskBoard.taskDialog.sprintStateCurrent": "Atual", + "taskBoard.taskDialog.sprintStatePrevious": "Anterior", + "taskBoard.taskDialog.sprintStateUpcoming": "Próxima", "taskBoard.taskDialog.startedByLabel": "iniciado por", "taskBoard.taskDialog.superAgentDefaultName": "Super Agent", "taskBoard.taskDialog.superAgentLabel": "Super Agent", + "taskBoard.taskDialog.tagsLabel": "Tags", "taskBoard.taskDialog.taskTitlePlaceholder": "Título da tarefa...", "taskBoard.taskDialog.threadStatusCompleted": "Concluído", "taskBoard.taskDialog.threadStatusError": "Erro", @@ -105,4 +171,6 @@ export const taskBoard = { "taskBoard.taskFilters.filterDrawerTitle": "Filtros", "taskBoard.taskFilters.priorityAnyPriority": "Qualquer prioridade", "taskBoard.taskFilters.priorityLabel": "Prioridade", + "taskBoard.taskFilters.tagAnyTag": "Qualquer tag", + "taskBoard.taskFilters.tagLabel": "Tag", } satisfies Record; diff --git a/apps/web/src/index.tsx b/apps/web/src/index.tsx index 6a82c6a364..64739e3211 100644 --- a/apps/web/src/index.tsx +++ b/apps/web/src/index.tsx @@ -485,6 +485,14 @@ const settingsConnectRoute = createRoute({ ), }); +const settingsBoardRoute = createRoute({ + getParentRoute: () => settingsLayout, + path: "/board", + component: lazyRouteComponent( + () => import("./routes/orgs/settings/board.tsx"), + ), +}); + const settingsBrandContextRoute = createRoute({ getParentRoute: () => settingsLayout, path: "/brand-context", @@ -609,6 +617,7 @@ const settingsWithChildren = settingsLayout.addChildren([ monitoringRoute, settingsGeneralRoute, settingsConnectRoute, + settingsBoardRoute, settingsBrandContextRoute, settingsAiProvidersRoute, settingsSecretsRoute, diff --git a/apps/web/src/layouts/settings-layout.tsx b/apps/web/src/layouts/settings-layout.tsx index a1720444ff..ef62b2572e 100644 --- a/apps/web/src/layouts/settings-layout.tsx +++ b/apps/web/src/layouts/settings-layout.tsx @@ -35,6 +35,7 @@ import { BarChart10, BookOpen01, Building02, + Columns03, ZapSquare, CpuChip01, Loading01, @@ -116,6 +117,13 @@ function useSettingsSidebarGroups(): SettingsNavGroup[] { to: "/$org/settings/brand-context", requires: "org:manage", }, + { + key: "board", + label: t("settings.nav.board"), + icon: , + to: "/$org/settings/board", + requires: "org:manage", + }, { key: "ai-providers", label: t("settings.nav.aiProviders"), diff --git a/apps/web/src/layouts/task-board/config.test.ts b/apps/web/src/layouts/task-board/config.test.ts index 418eb88a20..371b3d7c95 100644 --- a/apps/web/src/layouts/task-board/config.test.ts +++ b/apps/web/src/layouts/task-board/config.test.ts @@ -14,6 +14,12 @@ function item(id: string, sortOrder: number): TaskBoardItem { assignedBy: null, dueDate: null, sortOrder, + seq: null, + columnId: null, + tags: [], + sprintId: null, + releaseId: null, + externalKey: null, threads: [], createdBy: "user-1", createdAt: new Date().toISOString(), diff --git a/apps/web/src/layouts/task-board/config.tsx b/apps/web/src/layouts/task-board/config.tsx index e093d542d9..6b87bb132b 100644 --- a/apps/web/src/layouts/task-board/config.tsx +++ b/apps/web/src/layouts/task-board/config.tsx @@ -4,11 +4,22 @@ import { Circle, Eye, Loading02, + Package, + Rocket01, + SearchMd, } from "@untitledui/icons"; import type { StudioToolOutput as ToolOutput } from "@decocms/shared/tools/tool-io"; -import type { TranslationKey } from "@/i18n/use-t.ts"; +import type { TranslationKey, TFunction } from "@/i18n/use-t.ts"; +import type { TaskBoardColumnConfig } from "@decocms/shared/organization/schema"; +import { + columnForItem, + resolveBoardColumns, +} from "@decocms/shared/task-board-columns"; +import { useTaskBoardSettings } from "@/hooks/use-organization-settings"; export { SUPER_AGENT_ASSIGNEE_ID } from "@decocms/shared/task-board"; +export { columnForItem }; +export type BoardColumn = TaskBoardColumnConfig; export type TaskBoardItem = ToolOutput<"TASK_BOARD_ITEM_LIST">["items"][number]; export type TaskBoardItemStatus = TaskBoardItem["status"]; @@ -63,20 +74,121 @@ export function insertSortOrder( return 0; } +/** Short key prefix derived from the org slug (e.g. "osklen" → "OS"), the way + * trackers show PROJ-123. Stable per org; falls back to "T". */ +function taskKeyPrefix(orgSlug: string): string { + const alnum = orgSlug.replace(/[^a-zA-Z0-9]/g, ""); + return (alnum.slice(0, 2) || "T").toUpperCase(); +} + +/** The task's short key ("OS-42"), or null when it has no number yet. */ +export function formatTaskKey( + orgSlug: string, + seq: number | null, +): string | null { + return typeof seq === "number" ? `${taskKeyPrefix(orgSlug)}-${seq}` : null; +} + +/** Deterministic dot color for a label, so the same label reads the same + * everywhere (card chips + the labels picker). Raw palette (like the priority + * dots) — labels need distinct hues the design tokens don't provide. */ +const LABEL_DOT_COLORS = [ + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-green-500", + "bg-teal-500", + "bg-blue-500", + "bg-indigo-500", + "bg-purple-500", + "bg-pink-500", + "bg-slate-500", +]; +export function labelDotColor(label: string): string { + let hash = 0; + for (let i = 0; i < label.length; i++) { + hash = (hash * 31 + label.charCodeAt(i)) >>> 0; + } + return LABEL_DOT_COLORS[hash % LABEL_DOT_COLORS.length]!; +} + +/** + * Sprints are calendar weeks the system defines — you pick one, you never + * create them. A sprint id is the ISO date (YYYY-MM-DD) of that week's Monday, + * so a task's `sprintId` is just a week key. + */ +export type SprintWeekState = "current" | "upcoming" | "previous"; + +const SPRINT_DATE_FMT = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", +}); + +/** Local midnight Monday of the week containing `d`. */ +function startOfWeek(d: Date): Date { + const x = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + x.setDate(x.getDate() - ((x.getDay() + 6) % 7)); // Mon = 0 + return x; +} + +function toWeekKey(monday: Date): string { + const m = String(monday.getMonth() + 1).padStart(2, "0"); + const day = String(monday.getDate()).padStart(2, "0"); + return `${monday.getFullYear()}-${m}-${day}`; +} + +/** "Jul 21 – Jul 27" for a week key; falls back to the raw key if unparseable. */ +export function formatSprintRange(weekKey: string): string { + const start = new Date(`${weekKey}T00:00:00`); + if (Number.isNaN(start.getTime())) return weekKey; + const end = new Date(start); + end.setDate(end.getDate() + 6); + return `${SPRINT_DATE_FMT.format(start)} – ${SPRINT_DATE_FMT.format(end)}`; +} + +/** Where a week sits relative to today. */ +export function sprintWeekState(weekKey: string): SprintWeekState { + const start = new Date(`${weekKey}T00:00:00`); + const thisWeek = startOfWeek(new Date()); + if (toWeekKey(thisWeek) === weekKey) return "current"; + return start.getTime() > thisWeek.getTime() ? "upcoming" : "previous"; +} + +export function sprintStateLabelKey(state: SprintWeekState): TranslationKey { + return state === "current" + ? "taskBoard.taskDialog.sprintStateCurrent" + : state === "upcoming" + ? "taskBoard.taskDialog.sprintStateUpcoming" + : "taskBoard.taskDialog.sprintStatePrevious"; +} + +export function sprintStateTone(state: SprintWeekState): string { + return state === "current" ? "text-primary" : "text-muted-foreground"; +} + +/** + * The selectable weeks (sprints): current first, then the next 8 upcoming, then + * the last 4 previous — the system-defined window a task can be filed under. + */ +export function generateSprintWeeks(): string[] { + const monday = startOfWeek(new Date()); + const key = (offsetWeeks: number) => { + const d = new Date(monday); + d.setDate(d.getDate() + offsetWeeks * 7); + return toWeekKey(d); + }; + const weeks: string[] = []; + for (let i = 0; i <= 8; i++) weeks.push(key(i)); // current + upcoming + for (let i = -1; i >= -4; i--) weeks.push(key(i)); // previous (recent first) + return weeks; +} + /** Shape of an org member as returned by `useMembers()`, trimmed to the fields used here. */ export type Member = { userId: string; user?: { name?: string | null; image?: string | null }; }; -export const STATUSES: TaskBoardItemStatus[] = [ - "triage", - "todo", - "in_progress", - "in_review", - "done", -]; - export const STATUS_CONFIG: Record< TaskBoardItemStatus, { labelKey: TranslationKey; icon: typeof Circle; iconClassName: string } @@ -101,6 +213,21 @@ export const STATUS_CONFIG: Record< icon: Eye, iconClassName: "text-warning", }, + qa: { + labelKey: "taskBoard.config.statusQa", + icon: SearchMd, + iconClassName: "text-warning", + }, + ready_for_release: { + labelKey: "taskBoard.config.statusReadyForRelease", + icon: Package, + iconClassName: "text-primary", + }, + deploy: { + labelKey: "taskBoard.config.statusDeploy", + icon: Rocket01, + iconClassName: "text-primary", + }, done: { labelKey: "taskBoard.config.statusDone", icon: CheckCircle, @@ -108,6 +235,31 @@ export const STATUS_CONFIG: Record< }, }; +/** The org's effective board columns (defaults when unconfigured). */ +export function useBoardColumns(): BoardColumn[] { + const settings = useTaskBoardSettings(); + return resolveBoardColumns(settings?.columns); +} + +/** A column's display label — its custom name, or its stage's i18n label. */ +export function columnLabel(column: BoardColumn, t: TFunction): string { + return column.name ?? t(STATUS_CONFIG[column.stage].labelKey); +} + +/** + * The update payload that moves a task into `column`. Default columns (id == + * stage) keep columnId null so the default board stores no placement data. + */ +export function movePayload(column: BoardColumn): { + status: TaskBoardItemStatus; + columnId: string | null; +} { + return { + status: column.stage, + columnId: column.id === column.stage ? null : column.id, + }; +} + export const PRIORITIES: TaskBoardItemPriority[] = [ "none", "low", diff --git a/apps/web/src/layouts/task-board/index.tsx b/apps/web/src/layouts/task-board/index.tsx index 5fdc28e70f..9994fe92a4 100644 --- a/apps/web/src/layouts/task-board/index.tsx +++ b/apps/web/src/layouts/task-board/index.tsx @@ -12,13 +12,18 @@ import { useT } from "@/i18n/use-t.ts"; import { Avatar } from "@deco/ui/components/avatar.tsx"; import { Calendar, + Check, Columns03, HelpCircle, Lightning01, List, Loading01, Plus, + Rocket01, + RefreshCw01, + Trash03, UserPlus01, + X, } from "@untitledui/icons"; import { Popover, @@ -49,16 +54,26 @@ import { } from "@/hooks/use-task-board-items"; import { formatTimeAgo } from "@/lib/format-time"; import { + columnForItem, + columnLabel, + formatSprintRange, + formatTaskKey, + generateSprintWeeks, insertSortOrder, + labelDotColor, isTaskBlocked, + movePayload, primaryThread, PRIORITY_CONFIG, + sprintStateLabelKey, + sprintStateTone, + sprintWeekState, STATUS_CONFIG, - STATUSES, SUPER_AGENT_ASSIGNEE_ID, + useBoardColumns, + type BoardColumn, type TaskBoardItem, type TaskBoardItemPriority, - type TaskBoardItemStatus, type Member, } from "./config"; import { TaskBoardItemDialog } from "./task-dialog"; @@ -79,7 +94,16 @@ import { useThreadActions } from "@/components/chat/store/hooks"; import { writeChatDraft } from "@/lib/chat-draft"; import { createMentionDoc } from "@/components/chat/tiptap/mention"; import type { TiptapDoc } from "@/components/chat/types"; -import { useReportsOnly } from "@/hooks/use-organization-settings"; +import { + useReportsOnly, + useTaskBoardSettings, +} from "@/hooks/use-organization-settings"; +import { + useTaskBoardReleaseActions, + useTaskBoardReleases, +} from "@/hooks/use-task-board-releases"; +import { Input } from "@deco/ui/components/input.tsx"; +import { Textarea } from "@deco/ui/components/textarea.tsx"; import { BacklogPaywallBanner } from "./backlog-paywall"; // Warm the chat chunk so opening a task's activity doesn't cold-load it (flash). @@ -276,11 +300,26 @@ export function TaskBoardPage() { const [filters, setFilters] = useState(EMPTY_FILTERS); const [dialogOpen, setDialogOpen] = useState(false); const [editingItem, setEditingItem] = useState(null); - // Status a newly-created task should start in (set by a lane's "+"); null for - // the generic "New task" button. - const [createStatus, setCreateStatus] = useState( - null, + // Column a newly-created task should start in (set by a lane's "+"); null + // for the generic "New task" button. + const [createColumn, setCreateColumn] = useState(null); + + // Org board configuration: custom columns and the sprint/release toggles. + const boardSettings = useTaskBoardSettings(); + const columns = useBoardColumns(); + const sprintsEnabled = boardSettings?.sprintsEnabled ?? false; + const releasesEnabled = boardSettings?.releasesEnabled ?? false; + // "all" | "backlog" (no sprint) | a sprint id. Sprint list + management live + // in SprintControl; the board only tracks the current scope for filtering. + const [sprintScope, setSprintScope] = useState("all"); + // Release picking: a toolbar toggle turns cards into a multi-select; the + // floating bar below creates the release from the selection. + const releaseActions = useTaskBoardReleaseActions(); + const [releaseMode, setReleaseMode] = useState(false); + const [releaseSelection, setReleaseSelection] = useState>( + new Set(), ); + const [releaseDialogOpen, setReleaseDialogOpen] = useState(false); const { setTaskId } = usePanelActions(); const { create } = useThreadActions(); const studio = useStudioTools(); @@ -357,19 +396,38 @@ export function TaskBoardPage() { setTaskId(newId, agentId); }; - const visibleItems = items.filter((item) => - taskMatchesFilters(item, filters), + const matchesSprint = (item: TaskBoardItem) => { + if (!sprintsEnabled || sprintScope === "all") return true; + if (sprintScope === "backlog") return item.sprintId === null; + return item.sprintId === sprintScope; + }; + const visibleItems = items.filter( + (item) => taskMatchesFilters(item, filters) && matchesSprint(item), ); + const allTags = [...new Set(items.flatMap((i) => i.tags))].sort(); + + const toggleReleaseSelection = (id: string) => { + setReleaseSelection((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + const exitReleaseMode = () => { + setReleaseMode(false); + setReleaseSelection(new Set()); + }; const openCreate = () => { setEditingItem(null); - setCreateStatus(null); + setCreateColumn(null); setDialogOpen(true); }; - const openCreateInLane = (status: TaskBoardItemStatus) => { + const openCreateInLane = (column: BoardColumn) => { setEditingItem(null); - setCreateStatus(status); + setCreateColumn(column); setDialogOpen(true); }; @@ -390,7 +448,7 @@ export function TaskBoardPage() { const closeDialog = () => { setDialogOpen(false); setEditingItem(null); - setCreateStatus(null); + setCreateColumn(null); clearDeepLink(); }; @@ -406,7 +464,8 @@ export function TaskBoardPage() { // Full-width so each region's scroll container spans the whole panel — the // max-width lives on the *content* inside (header + lanes), so the mouse can // sit in the empty margins on wide monitors and still scroll the board. -
+ // Relative: anchors the release-mode floating action bar. +
{/* Header — capped + centered to the same width as the board content so they line up; content-capped, not scroll-capped. */}
@@ -427,6 +486,7 @@ export function TaskBoardPage() {
@@ -434,12 +494,17 @@ export function TaskBoardPage() {
)} + {sprintsEnabled && ( + + )} +
setReleaseMode(true)} + releaseSelection={releaseMode ? releaseSelection : null} + onOpen={ + releaseMode ? (item) => toggleReleaseSelection(item.id) : openTask + } onCreate={openCreateInLane} - onMove={(id, status, sortOrder) => - actions.update.mutate({ id, status, sortOrder }) + onMove={(id, column, sortOrder) => + actions.update.mutate({ id, ...movePayload(column), sortOrder }) } onAssign={(id, userId) => { if (blockSuperAgentWithoutGithub(userId)) return; @@ -534,16 +605,58 @@ export function TaskBoardPage() {
)} + {releaseMode && ( +
+
+ + {t("taskBoard.taskBoard.releaseSelectedCount", { + count: String(releaseSelection.size), + })} + + + +
+
+ )} + + + releaseActions.create.mutate( + { title, notes: notes || null, taskIds: [...releaseSelection] }, + { + onSuccess: () => { + setReleaseDialogOpen(false); + exitReleaseMode(); + }, + }, + ) + } + /> + { if (blockSuperAgentWithoutGithub(input.assigneeId)) { @@ -665,8 +778,12 @@ function LayoutToggle({ function Lanes({ items, + columns, members, memberByUserId, + releasesEnabled, + onNewRelease, + releaseSelection, onOpen, onCreate, onMove, @@ -674,16 +791,23 @@ function Lanes({ onAssign, }: { items: TaskBoardItem[]; + columns: BoardColumn[]; members: Member[]; memberByUserId: Map; + /** Show the trailing Releases column (org setting). */ + releasesEnabled: boolean; + /** Enter release-select mode from the column's "New release" action. */ + onNewRelease: () => void; + /** Non-null in release-select mode — the currently selected task ids. */ + releaseSelection: Set | null; onOpen: (item: TaskBoardItem) => void; - onCreate: (status: TaskBoardItemStatus) => void; - onMove: (id: string, status: TaskBoardItemStatus, sortOrder: number) => void; + onCreate: (column: BoardColumn) => void; + onMove: (id: string, column: BoardColumn, sortOrder: number) => void; onAutoFix?: (item: TaskBoardItem) => void; onAssign?: (id: string, userId: string | null) => void; }) { const t = useT(); - const [overLane, setOverLane] = useState(null); + const [overLane, setOverLane] = useState(null); // Which card the dragged one would land before, within `overLane` — null // means "at the end of the lane". Drives both the drop math and the // insertion-line indicator. @@ -691,7 +815,9 @@ function Lanes({ const boardRef = useRef(null); // Re-run FLIP whenever a card's lane or ordering changes. - const signature = items.map((t) => `${t.id}:${t.status}`).join(","); + const signature = items + .map((i) => `${i.id}:${columnForItem(i, columns).id}`) + .join(","); useFlipLanes(boardRef, signature); return ( @@ -706,16 +832,19 @@ function Lanes({ {/* Padding lives on the capped row (not the scroll container) so its left edge matches the header's max-w + px exactly. */}
- {STATUSES.map((status) => { - const laneItems = items.filter((t) => t.status === status); - const config = STATUS_CONFIG[status]; + {columns.map((column) => { + const laneItems = items.filter( + (i) => columnForItem(i, columns).id === column.id, + ); + const config = STATUS_CONFIG[column.stage]; const LaneIcon = config.icon; + const label = columnLabel(column, t); return (
{ e.preventDefault(); - setOverLane(status); + setOverLane(column.id); // Only reached when not over a card (cards stop propagation), // i.e. the empty area below the last card — drop at the end. setDropBeforeId(null); @@ -732,7 +861,7 @@ function Lanes({ if (id) { onMove( id, - status, + column, insertSortOrder(laneItems, dropBeforeId, id), ); } @@ -741,7 +870,7 @@ function Lanes({ }} className={cn( "flex w-[300px] shrink-0 flex-col rounded-xl p-1 transition-colors", - overLane === status && "bg-muted/50", + overLane === column.id && "bg-muted/50", )} > {/* Sticky so the column header stays visible while the cards @@ -752,20 +881,28 @@ function Lanes({ className={cn("shrink-0", config.iconClassName)} /> - {t(config.labelKey)} + {label} {laneItems.length} + {column.automation?.enabled && ( + + + + )}
); })} + {releasesEnabled && ( + + )}
); @@ -839,11 +984,156 @@ function DropDivider({ show }: { show: boolean }) { ); } +/** + * The board's trailing Releases column: each release is a card grouping the + * tasks stamped with it (ai-services panel's "a release is a card of the + * shipped issues"). "New release" enters card-select mode; the floating bar + * then composes the release from the selection. + */ +function ReleasesLane({ + items, + selecting, + onNewRelease, +}: { + items: TaskBoardItem[]; + selecting: boolean; + onNewRelease: () => void; +}) { + const t = useT(); + const { org } = useProjectContext(); + const { releases } = useTaskBoardReleases(true); + const actions = useTaskBoardReleaseActions(); + + const tasksByRelease = new Map(); + for (const it of items) { + if (!it.releaseId) continue; + const list = tasksByRelease.get(it.releaseId); + if (list) list.push(it); + else tasksByRelease.set(it.releaseId, [it]); + } + + return ( +
+
+ + + {t("taskBoard.taskBoard.releasesMenuLabel")} + + + {releases.length} + + {!selecting && ( + + )} +
+
+ {releases.length === 0 ? ( +
+ {t("taskBoard.taskBoard.noReleases")} +
+ ) : ( + releases.map((r) => { + const tasks = tasksByRelease.get(r.id) ?? []; + return ( +
+
+ + + {r.title} + + + {formatTimeAgo(new Date(r.createdAt))} + + +
+ {r.notes && ( +

+ {r.notes} +

+ )} +
+ {tasks.length === 0 ? ( + + {t("taskBoard.taskBoard.releaseNoTasks")} + + ) : ( + tasks.map((task) => ( +
+ {formatTaskKey(org.slug, task.seq) && ( + + {formatTaskKey(org.slug, task.seq)} + + )} + + {task.title} + +
+ )) + )} +
+
+ ); + }) + )} +
+
+ ); +} + +/** Label chips for a card/row — colored dot per label, first 3 plus "+N". */ +function TagChips({ tags }: { tags: string[] }) { + if (tags.length === 0) return null; + const shown = tags.slice(0, 3); + const extra = tags.length - shown.length; + return ( + <> + {shown.map((tag) => ( + + + {tag} + + ))} + {extra > 0 && ( + + +{extra} + + )} + + ); +} + function TaskCard({ item, assignee, assignedBy, members, + selected, onOpen, onAutoFix, onAssign, @@ -853,15 +1143,19 @@ function TaskCard({ assignee?: Member; assignedBy?: Member; members?: Member[]; + /** Non-null in release-select mode: whether this card is picked. */ + selected: boolean | null; onOpen: () => void; onAutoFix?: () => void; onAssign?: (userId: string | null) => void; onDragOverCard?: (e: DragEvent) => void; }) { const t = useT(); + const { org } = useProjectContext(); const statusConfig = STATUS_CONFIG[item.status]; const StatusIcon = statusConfig.icon; const lastMessage = primaryThread(item)?.lastMessage; + const taskKey = formatTaskKey(org.slug, item.seq); const showAutoFix = onAutoFix && @@ -880,9 +1174,18 @@ function TaskCard({ }} onDragOver={onDragOverCard} onClick={onOpen} - className="group flex cursor-grab flex-col gap-2 rounded-xl bg-card px-3 py-2.5 text-left card-shadow transition-colors will-change-transform hover:bg-accent/60 active:cursor-grabbing" + className={cn( + "group flex cursor-grab flex-col gap-2 rounded-xl bg-card px-3 py-2.5 text-left card-shadow transition-colors will-change-transform hover:bg-accent/60 active:cursor-grabbing", + selected && "ring-2 ring-primary", + )} title={item.title} > + {taskKey && ( + + {taskKey} + + )} +
0) && (
{isTaskBlocked(item) && } {item.priority !== "none" && ( )} {item.dueDate && } +
)} @@ -946,8 +1251,10 @@ function ListRow({ assignedBy?: Member; onOpen: () => void; }) { + const { org } = useProjectContext(); const config = STATUS_CONFIG[item.status]; const StatusIcon = config.icon; + const taskKey = formatTaskKey(org.slug, item.seq); return ( + + + + +
+ {weeks.map((week) => { + const state = sprintWeekState(week); + return ( + + ); + })} + + + ); +} + +/** Title + notes for a new release built from the selected cards. */ +function CreateReleaseDialog({ + open, + onOpenChange, + count, + isPending, + onCreate, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + count: number; + isPending: boolean; + onCreate: (title: string, notes: string) => void; +}) { + const t = useT(); + const [title, setTitle] = useState(""); + const [notes, setNotes] = useState(""); + return ( + + + + + {t("taskBoard.taskBoard.createReleaseTitle")} + + + {t("taskBoard.taskBoard.createReleaseDescription", { + count: String(count), + })} + + +
+ setTitle(e.target.value)} + placeholder={t("taskBoard.taskBoard.releaseTitlePlaceholder")} + autoFocus + /> +