+ );
+}
diff --git a/apps/web/e2e/tests/office/task-content-editing.spec.ts b/apps/web/e2e/tests/office/task-content-editing.spec.ts
new file mode 100644
index 0000000000..58f5446c2b
--- /dev/null
+++ b/apps/web/e2e/tests/office/task-content-editing.spec.ts
@@ -0,0 +1,220 @@
+import type { Browser, BrowserContext, Page } from "@playwright/test";
+import { test, expect } from "../../fixtures/office-fixture";
+import { watchWs } from "../../helpers/causal-waits";
+
+/**
+ * E2E coverage for in-place title and description editing on the Office task
+ * detail page (docs/specs/office-task-content-editing/spec.md).
+ *
+ * Title commits are optimistic (apply + close immediately, roll back on
+ * failure); description saves are not optimistic (editor stays open in a
+ * saving state, and a failure leaves the draft untouched). See
+ * hooks/use-optimistic-task-mutation.ts for both commit paths.
+ */
+
+async function gotoTaskPage(testPage: Page, taskId: string, title: string) {
+ await testPage.goto(`/office/tasks/${taskId}`);
+ await expect(testPage.getByRole("heading", { name: title })).toBeVisible({
+ timeout: 10_000,
+ });
+}
+
+/**
+ * Opens an independent client against the same backend: its own context means
+ * its own store, WebSocket and localStorage, which is what makes it a
+ * meaningful stand-in for a second browser tab observing the same task.
+ */
+async function openSecondClient(
+ browser: Browser,
+ frontendUrl: string,
+ backendPort: number,
+): Promise<{ page: Page; context: BrowserContext }> {
+ const context = await browser.newContext({ baseURL: frontendUrl });
+ const page = await context.newPage();
+ await page.addInitScript(
+ ({ port }: { port: number }) => {
+ localStorage.setItem("kandev.onboarding.completed", "true");
+ window.__KANDEV_API_PORT = String(port);
+ window.__KANDEV_E2E_EXPOSE_STORE__ = true;
+ },
+ { port: backendPort },
+ );
+ return { page, context };
+}
+
+async function mockTaskPatchFailure(testPage: Page, taskId: string) {
+ await testPage.route(
+ (url) =>
+ url.pathname === `/api/v1/tasks/${taskId}` || url.pathname.endsWith(`/tasks/${taskId}`),
+ async (route) => {
+ if (route.request().method() === "PATCH") {
+ await route.fulfill({
+ status: 500,
+ contentType: "application/json",
+ body: JSON.stringify({ error: "forced failure" }),
+ });
+ return;
+ }
+ await route.continue();
+ },
+ );
+}
+
+test.describe("task title and description editing", () => {
+ test("editing the title renders the trimmed value and persists it", async ({
+ testPage,
+ apiClient,
+ officeSeed,
+ }) => {
+ const task = await apiClient.createTask(officeSeed.workspaceId, "Original title task", {
+ workflow_id: officeSeed.workflowId,
+ });
+ await gotoTaskPage(testPage, task.id, "Original title task");
+
+ await testPage.getByTestId("office-task-title").dblclick();
+ const input = testPage.getByTestId("office-task-title-input");
+ await input.fill(" Renamed via inline edit ");
+ await input.press("Enter");
+
+ // AC-3: renders the trimmed draft immediately and closes the editor.
+ await expect(testPage.getByTestId("office-task-title-input")).toHaveCount(0);
+ await expect(testPage.getByTestId("office-task-title")).toHaveText("Renamed via inline edit");
+
+ // AC-26: persisted, and only the title field changed.
+ const persisted = (await apiClient.getTask(task.id)) as unknown as Record;
+ expect(persisted.title).toBe("Renamed via inline edit");
+ });
+
+ test("editing the description saves it, closes the editor, and persists it", async ({
+ testPage,
+ apiClient,
+ officeSeed,
+ }) => {
+ const task = await apiClient.createTask(officeSeed.workspaceId, "Description edit task", {
+ workflow_id: officeSeed.workflowId,
+ description: "Original description",
+ });
+ await gotoTaskPage(testPage, task.id, "Description edit task");
+
+ await testPage.getByTestId("office-task-description").click();
+ const textarea = testPage.getByTestId("office-task-description-textarea");
+ await expect(textarea).toHaveValue("Original description");
+ await textarea.fill(" Updated description body ");
+ await testPage.getByTestId("office-task-description-save").click();
+
+ // AC-16 + AC-17: save request fires, editor closes, saved value renders.
+ await expect(testPage.getByTestId("office-task-description-textarea")).toHaveCount(0, {
+ timeout: 10_000,
+ });
+ await expect(testPage.getByTestId("office-task-description")).toHaveText(
+ "Updated description body",
+ );
+
+ // AC-26: persisted, and only the description field changed.
+ const persisted = (await apiClient.getTask(task.id)) as unknown as Record;
+ expect(persisted.description).toBe("Updated description body");
+ });
+
+ test("a second browser context observes title and description edits without a reload", async ({
+ testPage,
+ apiClient,
+ officeSeed,
+ backend,
+ browser,
+ }) => {
+ test.setTimeout(120_000);
+ const task = await apiClient.createTask(officeSeed.workspaceId, "Cross-client edit task", {
+ workflow_id: officeSeed.workflowId,
+ description: "Starting description",
+ });
+
+ await gotoTaskPage(testPage, task.id, "Cross-client edit task");
+
+ const second = await openSecondClient(browser, backend.frontendUrl, backend.port);
+ const secondClientWs = watchWs(second.page);
+ await gotoTaskPage(second.page, task.id, "Cross-client edit task");
+
+ // Device A edits the title; device B must converge without reloading.
+ const titleUpdate = secondClientWs.waitForEvent("office.task.updated");
+ await testPage.getByTestId("office-task-title").dblclick();
+ const titleInput = testPage.getByTestId("office-task-title-input");
+ await titleInput.fill("Renamed on device A");
+ await titleInput.press("Enter");
+ await expect(testPage.getByTestId("office-task-title")).toHaveText("Renamed on device A");
+
+ // AC-27: the other client picks up the change from office.task.updated.
+ await titleUpdate;
+ await expect(second.page.getByTestId("office-task-title")).toHaveText("Renamed on device A");
+
+ // Device A edits the description; device B must converge without reloading.
+ const descriptionUpdate = secondClientWs.waitForEvent("office.task.updated");
+ await testPage.getByTestId("office-task-description").click();
+ const descriptionTextarea = testPage.getByTestId("office-task-description-textarea");
+ await descriptionTextarea.fill("Updated on device A");
+ await testPage.getByTestId("office-task-description-save").click();
+ await expect(testPage.getByTestId("office-task-description-textarea")).toHaveCount(0, {
+ timeout: 10_000,
+ });
+
+ await descriptionUpdate;
+ await expect(second.page.getByTestId("office-task-description")).toHaveText(
+ "Updated on device A",
+ );
+
+ await second.context.close();
+ });
+
+ test("a failed title save rolls back to the previous title and toasts", async ({
+ testPage,
+ apiClient,
+ officeSeed,
+ }) => {
+ const task = await apiClient.createTask(officeSeed.workspaceId, "Rollback title task", {
+ workflow_id: officeSeed.workflowId,
+ });
+ await gotoTaskPage(testPage, task.id, "Rollback title task");
+ await mockTaskPatchFailure(testPage, task.id);
+
+ await testPage.getByTestId("office-task-title").dblclick();
+ const input = testPage.getByTestId("office-task-title-input");
+ await input.fill("Title that will fail to save");
+ await input.press("Enter");
+
+ // AC-9: the optimistic render is reverted once the PATCH fails, and the
+ // stored title is what's restored (the "last word standing" case).
+ await expect(async () => {
+ const heading = testPage.getByTestId("office-task-title");
+ await expect(heading).toHaveText("Rollback title task");
+ }).toPass({ timeout: 5_000 });
+
+ const persisted = (await apiClient.getTask(task.id)) as unknown as Record;
+ expect(persisted.title).toBe("Rollback title task");
+ });
+
+ test("a failed description save keeps the editor open with the draft intact", async ({
+ testPage,
+ apiClient,
+ officeSeed,
+ }) => {
+ const task = await apiClient.createTask(officeSeed.workspaceId, "Rollback description task", {
+ workflow_id: officeSeed.workflowId,
+ description: "Original description",
+ });
+ await gotoTaskPage(testPage, task.id, "Rollback description task");
+ await mockTaskPatchFailure(testPage, task.id);
+
+ await testPage.getByTestId("office-task-description").click();
+ const textarea = testPage.getByTestId("office-task-description-textarea");
+ await textarea.fill("Draft that fails to save");
+ await testPage.getByTestId("office-task-description-save").click();
+
+ // AC-18: editor stays open, draft is untouched, nothing was persisted.
+ await expect(testPage.getByTestId("office-task-description-textarea")).toBeVisible();
+ await expect(testPage.getByTestId("office-task-description-textarea")).toHaveValue(
+ "Draft that fails to save",
+ );
+
+ const persisted = (await apiClient.getTask(task.id)) as unknown as Record;
+ expect(persisted.description).toBe("Original description");
+ });
+});
diff --git a/apps/web/hooks/domains/office/use-task-quorum.test.ts b/apps/web/hooks/domains/office/use-task-quorum.test.ts
index fe2995f26a..98faf4bc64 100644
--- a/apps/web/hooks/domains/office/use-task-quorum.test.ts
+++ b/apps/web/hooks/domains/office/use-task-quorum.test.ts
@@ -12,7 +12,7 @@ vi.mock("@/lib/api/domains/office-extended-api", () => ({
const setTaskQuorum = vi.fn();
let byTaskId: Record = {};
-let refetchTriggers: Record = {};
+let refetchTrigger: { types: string[]; timestamp: number } | null = null;
const WORKSPACE_ID = "workspace-1";
vi.mock("@/components/state-provider", () => ({
@@ -20,7 +20,7 @@ vi.mock("@/components/state-provider", () => ({
sel({
office: {
taskQuorum: { byTaskId },
- refetchTriggers,
+ refetchTrigger,
},
setTaskQuorum,
}),
@@ -32,7 +32,7 @@ describe("useTaskQuorum", () => {
mocks.getTaskQuorum.mockReset();
mocks.getTaskQuorum.mockResolvedValue({ guards: [], reevaluation_blocked: false });
byTaskId = {};
- refetchTriggers = {};
+ refetchTrigger = null;
});
it("fetches once on mount for a given task", async () => {
@@ -69,7 +69,7 @@ describe("useTaskQuorum", () => {
await waitFor(() => expect(mocks.getTaskQuorum).toHaveBeenCalledTimes(1));
rerender({ taskId: null });
- refetchTriggers = { "task:task-1": 1 };
+ refetchTrigger = { types: ["task:task-1"], timestamp: 1 };
rerender({ taskId: "task-1" });
await waitFor(() => expect(mocks.getTaskQuorum).toHaveBeenCalledTimes(2));
});
@@ -78,13 +78,13 @@ describe("useTaskQuorum", () => {
const { rerender } = renderHook(() => useTaskQuorum("task-1", WORKSPACE_ID));
await waitFor(() => expect(mocks.getTaskQuorum).toHaveBeenCalledTimes(1));
- refetchTriggers = { "task:task-1": 1 };
+ refetchTrigger = { types: ["task:task-1"], timestamp: 1 };
rerender();
await waitFor(() => expect(mocks.getTaskQuorum).toHaveBeenCalledTimes(2));
});
- it("does not refetch on mount just because the trigger map already has an unrelated entry", async () => {
- refetchTriggers = { "task:task-other": 3 };
+ it("does not refetch on mount just because the trigger already has an unrelated type", async () => {
+ refetchTrigger = { types: ["task:task-other"], timestamp: 3 };
renderHook(() => useTaskQuorum("task-1", WORKSPACE_ID));
await waitFor(() => expect(mocks.getTaskQuorum).toHaveBeenCalledTimes(1));
expect(mocks.getTaskQuorum).toHaveBeenCalledWith("task-1", WORKSPACE_ID);
diff --git a/apps/web/hooks/domains/office/use-task-quorum.ts b/apps/web/hooks/domains/office/use-task-quorum.ts
index 62ace46e1b..bfdd9e3406 100644
--- a/apps/web/hooks/domains/office/use-task-quorum.ts
+++ b/apps/web/hooks/domains/office/use-task-quorum.ts
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useAppStore } from "@/components/state-provider";
+import { useOfficeRefetch } from "@/hooks/use-office-refetch";
import { getTaskQuorum } from "@/lib/api/domains/office-extended-api";
import type { QuorumResponseDTO } from "@/lib/state/slices/office/quorum-types";
import { t } from "@/lib/i18n";
@@ -19,13 +20,6 @@ export function useTaskQuorum(
): UseTaskQuorumResult {
const quorum = useAppStore((s) => (taskId ? s.office.taskQuorum.byTaskId[taskId] : undefined));
const setTaskQuorum = useAppStore((s) => s.setTaskQuorum);
- // office.task.decision_recorded (lib/ws/handlers/office.ts) bumps this
- // counter for `task:${taskId}` — recording a decision changes the guard's
- // approve/reject count, so the cached snapshot must be invalidated even
- // though taskId itself hasn't changed.
- const decisionRefetchTrigger = useAppStore((s) =>
- taskId ? s.office.refetchTriggers[`task:${taskId}`] : undefined,
- );
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [fetchedScope, setFetchedScope] = useState(null);
@@ -58,10 +52,13 @@ export function useTaskQuorum(
void refresh();
}, [taskId, workspaceId, fetchedScope, refresh]);
- useEffect(() => {
- if (!taskId || !workspaceId || decisionRefetchTrigger === undefined) return;
- void refresh();
- }, [taskId, workspaceId, decisionRefetchTrigger, refresh]);
+ // office.task.decision_recorded (lib/ws/handlers/office.ts) fires
+ // `task:${taskId}` — recording a decision changes the guard's
+ // approve/reject count, so the cached snapshot must be invalidated even
+ // though taskId itself hasn't changed.
+ useOfficeRefetch(taskId ? `task:${taskId}` : "", () => {
+ if (taskId && workspaceId) void refresh();
+ });
return { quorum, isLoading, error, refresh };
}
diff --git a/apps/web/hooks/use-office-refetch.test.ts b/apps/web/hooks/use-office-refetch.test.ts
index 0cdd5b603a..e37d5a444c 100644
--- a/apps/web/hooks/use-office-refetch.test.ts
+++ b/apps/web/hooks/use-office-refetch.test.ts
@@ -1,144 +1,62 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-import { cleanup, renderHook } from "@testing-library/react";
-
-let storeState: { office: { refetchTriggers: Record } } = {
- office: { refetchTriggers: {} },
-};
-
-vi.mock("@/components/state-provider", () => ({
- useAppStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
-}));
-
+import { createElement, type ReactNode } from "react";
+import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { StateProvider, useAppStoreApi } from "@/components/state-provider";
import { useOfficeRefetch } from "./use-office-refetch";
-function bump(type: string) {
- const triggers = storeState.office.refetchTriggers;
- storeState = {
- office: { refetchTriggers: { ...triggers, [type]: (triggers[type] ?? 0) + 1 } },
- };
+afterEach(() => cleanup());
+
+function wrapper({ children }: { children: ReactNode }) {
+ return createElement(StateProvider, { children });
}
+/**
+ * office.task.updated fires `task:` and `dashboard` synchronously in the
+ * same handler. Both a task-detail listener and a dashboard listener must
+ * observe their own type from that one WS event — regression coverage for
+ * the same-tick batching in setOfficeRefetchTrigger (AC-27).
+ */
describe("useOfficeRefetch", () => {
- beforeEach(() => {
- storeState = { office: { refetchTriggers: {} } };
- });
+ it("notifies every listener whose type was fired in the same tick", async () => {
+ const taskCallback = vi.fn();
+ const dashboardCallback = vi.fn();
- afterEach(() => {
- cleanup();
- });
-
- it("does not fire on mount when no trigger has fired for this type", () => {
- const onRefetch = vi.fn();
- renderHook(() => useOfficeRefetch("dashboard", onRefetch));
- expect(onRefetch).not.toHaveBeenCalled();
- });
-
- it("fires when its exact type is bumped", () => {
- const onRefetch = vi.fn();
- const { rerender } = renderHook(() => useOfficeRefetch("dashboard", onRefetch));
-
- bump("dashboard");
- rerender();
-
- expect(onRefetch).toHaveBeenCalledTimes(1);
- });
-
- it("fires on a prefix match (comments: matches comments:task-123)", () => {
- const onRefetch = vi.fn();
- const { rerender } = renderHook(() => useOfficeRefetch("comments", onRefetch));
-
- bump("comments:task-123");
- rerender();
-
- expect(onRefetch).toHaveBeenCalledTimes(1);
- });
-
- it("does not fire for an unrelated type", () => {
- const onRefetch = vi.fn();
- const { rerender } = renderHook(() => useOfficeRefetch("dashboard", onRefetch));
-
- bump("tasks");
- rerender();
-
- expect(onRefetch).not.toHaveBeenCalled();
- });
-
- it("does not re-fire on a render that doesn't bump its type", () => {
- const onRefetch = vi.fn();
- const { rerender } = renderHook(() => useOfficeRefetch("dashboard", onRefetch));
-
- bump("dashboard");
- rerender();
- rerender();
- rerender();
-
- expect(onRefetch).toHaveBeenCalledTimes(1);
- });
-
- // Regression test: a single WS handler often bumps several distinct trigger
- // types in one synchronous call (e.g. `task:${id}` then `dashboard`), which
- // React coalesces into a single render. A shared "last trigger" store field
- // would overwrite itself on every bump, so only the final type's subscriber
- // would ever observe a change — every other type silently never refetches.
- // Per-type counters must let every matching subscriber see its own bump
- // even when several types were bumped before React ever renders.
- it("lets every subscriber observe its own bump when multiple types are bumped in the same tick", () => {
- const onTaskRefetch = vi.fn();
- const onDashboardRefetch = vi.fn();
- const { rerender: rerenderTask } = renderHook(() =>
- useOfficeRefetch("task:t-1", onTaskRefetch),
- );
- const { rerender: rerenderDashboard } = renderHook(() =>
- useOfficeRefetch("dashboard", onDashboardRefetch),
+ const { result } = renderHook(
+ () => {
+ useOfficeRefetch("task:t-1", taskCallback);
+ useOfficeRefetch("dashboard", dashboardCallback);
+ return useAppStoreApi();
+ },
+ { wrapper },
);
- // Simulate a WS handler firing both triggers before either hook re-renders.
- bump("task:t-1");
- bump("dashboard");
- rerenderTask();
- rerenderDashboard();
-
- expect(onTaskRefetch).toHaveBeenCalledTimes(1);
- expect(onDashboardRefetch).toHaveBeenCalledTimes(1);
- });
-
- // Regression test: a prefix subscriber watches several sub-keys under one
- // triggerType (e.g. "comments" matches "comments:task-A" and
- // "comments:task-B"). A single scalar "last seen" value would let a bump
- // on one sub-key mask a later bump on a sibling sub-key whenever the
- // sibling's counter is lower than the max already observed. Per-key
- // tracking must let each sub-key's bump fire independently.
- it("still fires for a sub-key bump after a higher-numbered sibling sub-key already fired", () => {
- const onRefetch = vi.fn();
- const { rerender } = renderHook(() => useOfficeRefetch("comments", onRefetch));
-
- // task-B races ahead first, bumped three times.
- bump("comments:task-B");
- bump("comments:task-B");
- bump("comments:task-B");
- rerender();
- expect(onRefetch).toHaveBeenCalledTimes(1);
-
- // task-A's first bump (counter 1) is lower than task-B's counter (3), so
- // a scalar "max seen" comparison would swallow it.
- bump("comments:task-A");
- rerender();
-
- expect(onRefetch).toHaveBeenCalledTimes(2);
- });
-
- it("uses the latest callback without re-firing when only the callback changes", () => {
- const first = vi.fn();
- const second = vi.fn();
- const { rerender } = renderHook(({ cb }) => useOfficeRefetch("dashboard", cb), {
- initialProps: { cb: first },
+ act(() => {
+ result.current.getState().setOfficeRefetchTrigger("task:t-1");
+ result.current.getState().setOfficeRefetchTrigger("dashboard");
});
- rerender({ cb: second });
- bump("dashboard");
- rerender({ cb: second });
+ await waitFor(() => expect(taskCallback).toHaveBeenCalledTimes(1));
+ await waitFor(() => expect(dashboardCallback).toHaveBeenCalledTimes(1));
+ });
- expect(first).not.toHaveBeenCalled();
- expect(second).toHaveBeenCalledTimes(1);
+ it("prefix-matches a task channel without notifying an unrelated task", async () => {
+ const matchingCallback = vi.fn();
+ const otherCallback = vi.fn();
+
+ const { result } = renderHook(
+ () => {
+ useOfficeRefetch("task:t-1", matchingCallback);
+ useOfficeRefetch("task:t-2", otherCallback);
+ return useAppStoreApi();
+ },
+ { wrapper },
+ );
+
+ act(() => {
+ result.current.getState().setOfficeRefetchTrigger("task:t-1");
+ });
+
+ await waitFor(() => expect(matchingCallback).toHaveBeenCalledTimes(1));
+ expect(otherCallback).not.toHaveBeenCalled();
});
});
diff --git a/apps/web/hooks/use-office-refetch.ts b/apps/web/hooks/use-office-refetch.ts
index a72e32f208..f6208677a0 100644
--- a/apps/web/hooks/use-office-refetch.ts
+++ b/apps/web/hooks/use-office-refetch.ts
@@ -1,48 +1,34 @@
import { useEffect, useLayoutEffect, useRef } from "react";
import { useAppStore } from "@/components/state-provider";
-// Any real counter the store can hold is >= 1, so 0 reliably "looks bumped"
-// on the very first observed value and "looks unbumped" otherwise — see
-// use-session-commits.ts for the same sentinel pattern.
-const REFETCH_TRIGGER_INIT = 0;
-
/**
* Calls `onRefetch` when a matching office refetch trigger fires.
* Supports exact match ("dashboard") or prefix match ("comments:" matches "comments:task-123").
*
- * Triggers are per-type counters (`office.refetchTriggers`), not a single
- * "last trigger" value: a WS handler often bumps several distinct types in
- * one synchronous call (e.g. `task:${id}` then `dashboard`), and React
- * coalesces those into one render, so a shared last-value field would only
+ * Triggers are batched into a single `office.refetchTrigger` object per tick
+ * rather than tracked as separate values: a WS handler often bumps several
+ * distinct types in one synchronous call (e.g. `task:${id}` then `dashboard`),
+ * and React coalesces those into one render, so per-type values would only
* ever be observable by the final type's subscribers.
*
* @param triggerType - The trigger type to watch for (e.g. "dashboard", "tasks", "comments")
* @param onRefetch - Callback invoked when a matching trigger fires
*/
export function useOfficeRefetch(triggerType: string, onRefetch: () => void) {
- const triggers = useAppStore((s) => s.office.refetchTriggers);
+ const trigger = useAppStore((s) => s.office.refetchTrigger);
const callbackRef = useRef(onRefetch);
// Update ref in a layout effect to avoid mutating during render
useLayoutEffect(() => {
callbackRef.current = onRefetch;
});
- // Tracks the highest counter we've already acted on, per matching key, so
- // a bump triggers exactly one refetch rather than re-firing on every
- // render. A single scalar here would let a bump on one sub-key (e.g.
- // "comments:task-B") mask a later bump on a sibling sub-key ("comments:
- // task-A") whenever the sibling's counter happened to be lower.
- const lastSeenRef = useRef>({});
-
useEffect(() => {
- let fire = false;
- for (const [type, seq] of Object.entries(triggers)) {
- if (type !== triggerType && !type.startsWith(triggerType + ":")) continue;
- if (seq > (lastSeenRef.current[type] ?? REFETCH_TRIGGER_INIT)) {
- lastSeenRef.current[type] = seq;
- fire = true;
- }
+ if (!trigger) return;
+ const matches = trigger.types.some(
+ (type) => type === triggerType || type.startsWith(triggerType + ":"),
+ );
+ if (matches) {
+ callbackRef.current();
}
- if (fire) callbackRef.current();
- }, [triggers, triggerType]);
+ }, [trigger, triggerType]);
}
diff --git a/apps/web/hooks/use-optimistic-task-mutation-rollback-ordering.test.tsx b/apps/web/hooks/use-optimistic-task-mutation-rollback-ordering.test.tsx
new file mode 100644
index 0000000000..d47bf606ac
--- /dev/null
+++ b/apps/web/hooks/use-optimistic-task-mutation-rollback-ordering.test.tsx
@@ -0,0 +1,228 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { act, cleanup, render } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { StateProvider, useAppStoreApi } from "@/components/state-provider";
+import { TaskOptimisticContextProvider, useCommitTaskTitle } from "./use-optimistic-task-mutation";
+import type { Task } from "@/app/office/tasks/[id]/types";
+import type { OfficeTask } from "@/lib/state/slices/office/types";
+import type { Task as HttpTask } from "@/lib/types/http";
+import {
+ __resetOfficeTaskContentSyncForTests,
+ seedInitialCanonical,
+} from "@/lib/state/office-task-content-sync";
+
+vi.mock("sonner", () => ({
+ toast: {
+ error: vi.fn(),
+ success: vi.fn(),
+ },
+}));
+
+vi.mock("@/lib/api/domains/kanban-api", () => ({
+ updateTask: vi.fn(),
+}));
+
+import { toast } from "sonner";
+import { updateTask } from "@/lib/api/domains/kanban-api";
+
+const mockUpdateTask = vi.mocked(updateTask);
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ __resetOfficeTaskContentSyncForTests();
+});
+
+const TS = "2026-05-01T00:00:00Z";
+const ORIGINAL_TITLE = "Original Title";
+const WRITE_FAILURE_MESSAGE = "boom";
+
+const baseTask: Task = {
+ id: "t-1",
+ workspaceId: "ws-1",
+ identifier: "TASK-1",
+ title: "First task",
+ status: "todo",
+ priority: "medium",
+ labels: [],
+ blockedBy: [],
+ blocking: [],
+ children: [],
+ reviewers: [],
+ approvers: [],
+ decisions: [],
+ createdBy: "user",
+ createdAt: TS,
+ updatedAt: TS,
+};
+
+const baseOfficeTask: OfficeTask = {
+ id: "t-1",
+ workspaceId: "ws-1",
+ identifier: "TASK-1",
+ title: "First task",
+ status: "todo",
+ priority: "medium",
+ createdAt: TS,
+ updatedAt: TS,
+};
+
+function makeHarness(initialTask: Task, initialOffice: OfficeTask | null) {
+ const state = {
+ task: initialTask,
+ patches: [] as Partial[],
+ };
+ const ctxValue = {
+ task: initialTask,
+ applyPatch: (patch: Partial) => {
+ state.patches.push(patch);
+ state.task = { ...state.task, ...patch };
+ },
+ restore: (snapshot: Task) => {
+ state.task = snapshot;
+ },
+ };
+ function StoreSeed({ children }: { children: ReactNode }) {
+ const api = useAppStoreApi();
+ if (initialOffice) {
+ api.getState().setTasks([initialOffice]);
+ }
+ return <>{children}>;
+ }
+ function Wrapper({ children }: { children: ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
+ }
+ return { Wrapper, state };
+}
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+function TitleHookProbe({
+ onReady,
+}: {
+ onReady: (
+ commit: ReturnType,
+ storeApi: ReturnType,
+ ) => void;
+}) {
+ const commit = useCommitTaskTitle();
+ const storeApi = useAppStoreApi();
+ onReady(commit, storeApi);
+ return null;
+}
+
+describe("useCommitTaskTitle rollback ordering — AC-65 chained failures", () => {
+ it("chained double failure: restores only once both commits have resolved, regardless of resolution order (AC-65)", async () => {
+ seedInitialCanonical("t-1", "title", ORIGINAL_TITLE, TS);
+ const { Wrapper, state } = makeHarness(baseTask, baseOfficeTask);
+ let commit: ReturnType | null = null;
+ let storeApi: ReturnType | null = null;
+ render(
+
+ ([commit, storeApi] = [c, s])} />
+ ,
+ );
+
+ const first = deferred();
+ const second = deferred();
+ mockUpdateTask.mockReturnValueOnce(first.promise);
+ mockUpdateTask.mockReturnValueOnce(second.promise);
+
+ let p1!: Promise;
+ let p2!: Promise;
+ act(() => {
+ p1 = commit!("t-1", "A");
+ p2 = commit!("t-1", "B");
+ });
+
+ // AC-53: the earlier commit (A) fails first, but B is still unresolved,
+ // so no restore happens yet — the displayed title stays at B's draft.
+ await act(async () => {
+ first.reject(new Error(WRITE_FAILURE_MESSAGE));
+ await p1;
+ });
+ expect(state.task.title).toBe("B");
+ expect(storeApi!.getState().office.tasks.items[0]?.title).toBe("B");
+
+ // AC-65: the later commit (B) now also fails. Nothing is left pending,
+ // so this is the restore that actually fires — to the recorded canonical
+ // value, not to either commit's draft. Assert both the displayed title
+ // and the Office task store entry, per the spec's explicit "both end at
+ // the canonical title" requirement for this regression test.
+ await act(async () => {
+ second.reject(new Error(WRITE_FAILURE_MESSAGE));
+ await p2;
+ });
+ expect(state.task.title).toBe(ORIGINAL_TITLE);
+ expect(storeApi!.getState().office.tasks.items[0]?.title).toBe(ORIGINAL_TITLE);
+ expect(state.patches).toEqual([{ title: "A" }, { title: "B" }, { title: ORIGINAL_TITLE }]);
+ expect(toast.error).toHaveBeenCalledTimes(2);
+ });
+
+ it("chained double failure: restores as soon as the later commit resolves, even if it fails first (AC-65)", async () => {
+ seedInitialCanonical("t-1", "title", ORIGINAL_TITLE, TS);
+ const { Wrapper, state } = makeHarness(baseTask, baseOfficeTask);
+ let commit: ReturnType | null = null;
+ let storeApi: ReturnType | null = null;
+ render(
+
+ ([commit, storeApi] = [c, s])} />
+ ,
+ );
+
+ const first = deferred();
+ const second = deferred();
+ mockUpdateTask.mockReturnValueOnce(first.promise);
+ mockUpdateTask.mockReturnValueOnce(second.promise);
+
+ let p1!: Promise;
+ let p2!: Promise;
+ act(() => {
+ p1 = commit!("t-1", "A");
+ p2 = commit!("t-1", "B");
+ });
+
+ // The later commit (B) fails first, and nothing later is pending, so it
+ // restores immediately to canonical.
+ await act(async () => {
+ second.reject(new Error(WRITE_FAILURE_MESSAGE));
+ await p2;
+ });
+ expect(state.task.title).toBe(ORIGINAL_TITLE);
+ expect(storeApi!.getState().office.tasks.items[0]?.title).toBe(ORIGINAL_TITLE);
+
+ // The earlier commit (A) then also fails. B already resolved, so this
+ // restore fires too (per AC-65's "restore in either resolution order"),
+ // converging on the same canonical value rather than clobbering it —
+ // asserted on both the displayed title and the Office task store entry
+ // so this reaches the same end state as the reverse resolution order
+ // above regardless of which layer is read.
+ await act(async () => {
+ first.reject(new Error(WRITE_FAILURE_MESSAGE));
+ await p1;
+ });
+ expect(state.task.title).toBe(ORIGINAL_TITLE);
+ expect(storeApi!.getState().office.tasks.items[0]?.title).toBe(ORIGINAL_TITLE);
+ expect(state.patches).toEqual([
+ { title: "A" },
+ { title: "B" },
+ { title: ORIGINAL_TITLE },
+ { title: ORIGINAL_TITLE },
+ ]);
+ expect(toast.error).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/apps/web/hooks/use-optimistic-task-mutation.test.tsx b/apps/web/hooks/use-optimistic-task-mutation.test.tsx
index 8bf20dba52..2c46011b35 100644
--- a/apps/web/hooks/use-optimistic-task-mutation.test.tsx
+++ b/apps/web/hooks/use-optimistic-task-mutation.test.tsx
@@ -4,10 +4,19 @@ import type { ReactNode } from "react";
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
import {
TaskOptimisticContextProvider,
+ useCommitTaskDescription,
+ useCommitTaskTitle,
useOptimisticTaskMutation,
} from "./use-optimistic-task-mutation";
import type { Task } from "@/app/office/tasks/[id]/types";
import type { OfficeTask } from "@/lib/state/slices/office/types";
+import type { Task as HttpTask } from "@/lib/types/http";
+import {
+ __resetOfficeTaskContentSyncForTests,
+ getCanonicalValue,
+ seedInitialCanonical,
+ subscribeField,
+} from "@/lib/state/office-task-content-sync";
vi.mock("sonner", () => ({
toast: {
@@ -16,11 +25,19 @@ vi.mock("sonner", () => ({
},
}));
+vi.mock("@/lib/api/domains/kanban-api", () => ({
+ updateTask: vi.fn(),
+}));
+
import { toast } from "sonner";
+import { updateTask } from "@/lib/api/domains/kanban-api";
+
+const mockUpdateTask = vi.mocked(updateTask);
afterEach(() => {
cleanup();
vi.clearAllMocks();
+ __resetOfficeTaskContentSyncForTests();
});
const TS = "2026-05-01T00:00:00Z";
@@ -154,6 +171,48 @@ describe("useOptimisticTaskMutation", () => {
expect(toast.error).toHaveBeenCalledWith("Update failed");
});
+ it("never restores title/description in the store, even when a confirmed edit lands mid-flight (AC-61)", async () => {
+ const { Wrapper } = makeHarness(baseTask, baseOfficeTask);
+ let mutate: ReturnType | null = null;
+ let storeApi: ReturnType | null = null;
+ function Probe() {
+ const m = useOptimisticTaskMutation();
+ const s = useAppStoreApi();
+ mutate = m;
+ storeApi = s;
+ return null;
+ }
+ render(
+
+
+ ,
+ );
+ const { promise, reject } = deferred();
+ const apiCall = vi.fn().mockReturnValue(promise);
+
+ let pending!: Promise;
+ act(() => {
+ pending = mutate!("t-1", { priority: "high" }, apiCall);
+ });
+
+ // A title commit succeeds server-side and lands in the store while this
+ // unrelated priority mutation is still in flight (e.g. useCommitTaskTitle
+ // resolving concurrently).
+ act(() => {
+ storeApi!.getState().patchTaskInStore("t-1", { title: "Confirmed New Title" });
+ });
+
+ await act(async () => {
+ reject(new Error("nope"));
+ await expect(pending).rejects.toThrow("nope");
+ });
+
+ // The failed priority mutation's rollback must not clobber the
+ // meanwhile-confirmed title back to its pre-mutation snapshot value.
+ expect(storeApi!.getState().office.tasks.items[0]?.title).toBe("Confirmed New Title");
+ expect(storeApi!.getState().office.tasks.items[0]?.priority).toBe("medium");
+ });
+
it("works when the office store has no entry for the task", async () => {
const { Wrapper, state } = makeHarness(baseTask, null);
let mutate: ReturnType | null = null;
@@ -170,3 +229,456 @@ describe("useOptimisticTaskMutation", () => {
expect(state.task.status).toBe("done");
});
});
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+function taskResponse(overrides: Partial & { title: string; description: string }) {
+ return {
+ id: "t-1",
+ workspace_id: "ws-1",
+ workflow_id: "wf-1",
+ workflow_step_id: "step-1",
+ position: 0,
+ state: "TODO",
+ priority: "medium",
+ created_at: TS,
+ updated_at: TS,
+ ...overrides,
+ } as unknown as HttpTask;
+}
+
+const NEW_TITLE = "New Title";
+const ORIGINAL_TITLE = "Original Title";
+const WRITE_FAILURE_MESSAGE = "boom";
+const WRITE_RESPONSE_TS = "2026-05-02T00:00:00Z";
+const NEW_DESCRIPTION = "New description";
+
+function TitleHookProbe({
+ onReady,
+}: {
+ onReady: (
+ commit: ReturnType,
+ storeApi: ReturnType,
+ ) => void;
+}) {
+ const commit = useCommitTaskTitle();
+ const storeApi = useAppStoreApi();
+ onReady(commit, storeApi);
+ return null;
+}
+
+describe("useCommitTaskTitle", () => {
+ it("patches the local task and store optimistically at issue time, and keeps it on success (AC-3, AC-11)", async () => {
+ const { Wrapper, state } = makeHarness(baseTask, baseOfficeTask);
+ let commit: ReturnType | null = null;
+ let storeApi: ReturnType | null = null;
+ const { promise, resolve } = deferred>();
+ mockUpdateTask.mockReturnValueOnce(promise);
+ render(
+
+ ([commit, storeApi] = [c, s])} />
+ ,
+ );
+
+ let pending!: Promise;
+ act(() => {
+ pending = commit!("t-1", NEW_TITLE);
+ });
+ expect(state.patches).toEqual([{ title: NEW_TITLE }]);
+ expect(mockUpdateTask).toHaveBeenCalledWith("t-1", { title: NEW_TITLE });
+ expect(storeApi!.getState().office.tasks.items[0]?.title).toBe(NEW_TITLE);
+
+ await act(async () => {
+ resolve(taskResponse({ title: NEW_TITLE, description: "", updated_at: WRITE_RESPONSE_TS }));
+ await pending;
+ });
+ expect(state.task.title).toBe(NEW_TITLE);
+ expect(storeApi!.getState().office.tasks.items[0]?.title).toBe(NEW_TITLE);
+ expect(toast.error).not.toHaveBeenCalled();
+ });
+});
+
+describe("useCommitTaskTitle write-success canonical (AC-58)", () => {
+ it("records the write's own response as canonical, and a subscribeField listener observes the saved text (not the pre-write baseline) once the guard releases, with no refetch in play", async () => {
+ seedInitialCanonical("t-1", "title", ORIGINAL_TITLE, TS);
+ const { Wrapper } = makeHarness(baseTask, baseOfficeTask);
+ let commit: ReturnType | null = null;
+ mockUpdateTask.mockResolvedValueOnce(
+ taskResponse({ title: NEW_TITLE, description: "", updated_at: WRITE_RESPONSE_TS }),
+ );
+ render(
+
+ (commit = c)} />
+ ,
+ );
+
+ const seen: string[] = [];
+ subscribeField("t-1", "title", (value) => seen.push(value));
+
+ await act(async () => {
+ await commit!("t-1", NEW_TITLE);
+ });
+
+ expect(getCanonicalValue("t-1", "title")).toBe(NEW_TITLE);
+ // The field is guarded (beginWrite) for the whole commit, so the
+ // listener fires exactly once, after endWrite releases the guard, with
+ // the saved value — never with the seeded pre-write baseline.
+ expect(seen).toEqual([NEW_TITLE]);
+ });
+});
+
+describe("useCommitTaskTitle rollback ordering", () => {
+ it("restores the currently recorded canonical title on failure when no later commit is in play (AC-9, AC-64)", async () => {
+ seedInitialCanonical("t-1", "title", ORIGINAL_TITLE, TS);
+ const { Wrapper, state } = makeHarness(baseTask, baseOfficeTask);
+ let commit: ReturnType | null = null;
+ mockUpdateTask.mockRejectedValueOnce(new Error(WRITE_FAILURE_MESSAGE));
+ render(
+
+ (commit = c)} />
+ ,
+ );
+
+ await act(async () => {
+ await commit!("t-1", NEW_TITLE);
+ });
+
+ expect(state.patches).toEqual([{ title: NEW_TITLE }, { title: ORIGINAL_TITLE }]);
+ expect(state.task.title).toBe(ORIGINAL_TITLE);
+ expect(toast.error).toHaveBeenCalledWith(WRITE_FAILURE_MESSAGE);
+ });
+
+ it("does not restore when a later-sequenced commit already succeeded (AC-44)", async () => {
+ seedInitialCanonical("t-1", "title", ORIGINAL_TITLE, TS);
+ const { Wrapper, state } = makeHarness(baseTask, baseOfficeTask);
+ let commit: ReturnType | null = null;
+ render(
+
+ (commit = c)} />
+ ,
+ );
+
+ const first = deferred>();
+ const second = deferred>();
+ mockUpdateTask.mockReturnValueOnce(first.promise);
+ mockUpdateTask.mockReturnValueOnce(second.promise);
+
+ let p1!: Promise;
+ let p2!: Promise;
+ act(() => {
+ p1 = commit!("t-1", "A");
+ p2 = commit!("t-1", "B");
+ });
+
+ await act(async () => {
+ second.resolve(taskResponse({ title: "B", description: "", updated_at: WRITE_RESPONSE_TS }));
+ await p2;
+ first.reject(new Error(WRITE_FAILURE_MESSAGE));
+ await p1;
+ });
+
+ expect(state.patches).toEqual([{ title: "A" }, { title: "B" }]);
+ expect(state.task.title).toBe("B");
+ expect(toast.error).toHaveBeenCalledWith(WRITE_FAILURE_MESSAGE);
+ });
+
+ it("does not restore when a later-sequenced commit is still unresolved (AC-53)", async () => {
+ seedInitialCanonical("t-1", "title", ORIGINAL_TITLE, TS);
+ const { Wrapper, state } = makeHarness(baseTask, baseOfficeTask);
+ let commit: ReturnType | null = null;
+ render(
+
+ (commit = c)} />
+ ,
+ );
+
+ const first = deferred>();
+ const second = deferred>();
+ mockUpdateTask.mockReturnValueOnce(first.promise);
+ mockUpdateTask.mockReturnValueOnce(second.promise);
+
+ let p1!: Promise;
+ act(() => {
+ p1 = commit!("t-1", "A");
+ commit!("t-1", "B");
+ });
+
+ await act(async () => {
+ first.reject(new Error(WRITE_FAILURE_MESSAGE));
+ await p1;
+ });
+
+ // The later commit (B) is still unresolved, so the earlier failure (A)
+ // must not restore anything yet.
+ expect(state.patches).toEqual([{ title: "A" }, { title: "B" }]);
+ expect(state.task.title).toBe("B");
+ expect(toast.error).toHaveBeenCalledWith(WRITE_FAILURE_MESSAGE);
+
+ await act(async () => {
+ second.resolve(taskResponse({ title: "B", description: "", updated_at: WRITE_RESPONSE_TS }));
+ });
+ });
+});
+
+// AC-65's chained-double-failure regression tests live in
+// use-optimistic-task-mutation-rollback-ordering.test.tsx (kept out of this
+// file to stay under the max-lines limit).
+
+describe("useCommitTaskTitle rollback field scoping", () => {
+ it("restores only the title, leaving an unrelated store field changed in the meantime untouched (AC-55)", async () => {
+ seedInitialCanonical("t-1", "title", ORIGINAL_TITLE, TS);
+ const { Wrapper } = makeHarness(baseTask, baseOfficeTask);
+ let commit: ReturnType | null = null;
+ let storeApi: ReturnType | null = null;
+ const { promise, reject } = deferred>();
+ mockUpdateTask.mockReturnValueOnce(promise);
+ render(
+
+ ([commit, storeApi] = [c, s])} />
+ ,
+ );
+
+ let pending!: Promise;
+ act(() => {
+ pending = commit!("t-1", NEW_TITLE);
+ });
+
+ // A second, unrelated store field changes while the title write is
+ // still in flight (e.g. a concurrent priority change from elsewhere).
+ act(() => {
+ storeApi!.getState().patchTaskInStore("t-1", { priority: "high" });
+ });
+
+ await act(async () => {
+ reject(new Error(WRITE_FAILURE_MESSAGE));
+ await pending;
+ });
+
+ expect(storeApi!.getState().office.tasks.items[0]?.title).toBe(ORIGINAL_TITLE);
+ expect(storeApi!.getState().office.tasks.items[0]?.priority).toBe("high");
+ });
+});
+
+function DescriptionHookProbe({
+ onReady,
+}: {
+ onReady: (commit: ReturnType) => void;
+}) {
+ const commit = useCommitTaskDescription();
+ onReady(commit);
+ return null;
+}
+
+describe("useCommitTaskDescription", () => {
+ it("does not patch until the save succeeds (AC-56)", async () => {
+ const { Wrapper, state } = makeHarness(baseTask, baseOfficeTask);
+ let commit: ReturnType | null = null;
+ const { promise, resolve } = deferred>();
+ mockUpdateTask.mockReturnValueOnce(promise);
+ render(
+
+ (commit = c)} />
+ ,
+ );
+
+ let pending!: Promise<{ ok: true; value: string } | { ok: false }>;
+ act(() => {
+ pending = commit!("t-1", NEW_DESCRIPTION);
+ });
+ expect(state.patches).toEqual([]);
+ expect(mockUpdateTask).toHaveBeenCalledWith("t-1", { description: NEW_DESCRIPTION });
+
+ let result!: { ok: true; value: string } | { ok: false };
+ await act(async () => {
+ resolve(
+ taskResponse({
+ title: "First task",
+ description: NEW_DESCRIPTION,
+ updated_at: WRITE_RESPONSE_TS,
+ }),
+ );
+ result = await pending;
+ });
+ expect(state.patches).toEqual([{ description: NEW_DESCRIPTION }]);
+ expect(result).toEqual({ ok: true, value: NEW_DESCRIPTION });
+ });
+
+ it("leaves the draft state untouched, toasts, and reports failure (AC-18)", async () => {
+ const { Wrapper, state } = makeHarness(baseTask, baseOfficeTask);
+ let commit: ReturnType | null = null;
+ mockUpdateTask.mockRejectedValueOnce(new Error("nope"));
+ render(
+
+ (commit = c)} />
+ ,
+ );
+
+ let result!: { ok: true; value: string } | { ok: false };
+ await act(async () => {
+ result = await commit!("t-1", NEW_DESCRIPTION);
+ });
+ expect(state.patches).toEqual([]);
+ expect(result).toEqual({ ok: false });
+ expect(toast.error).toHaveBeenCalledWith("nope");
+ });
+});
+
+describe("useCommitTaskDescription write-success canonical (AC-58)", () => {
+ it("records the write's own response as canonical, and a subscribeField listener observes the saved text (not the pre-write baseline) once the guard releases, with no refetch in play", async () => {
+ seedInitialCanonical("t-1", "description", "Original description", TS);
+ const { Wrapper } = makeHarness(baseTask, baseOfficeTask);
+ let commit: ReturnType | null = null;
+ mockUpdateTask.mockResolvedValueOnce(
+ taskResponse({
+ title: baseTask.title,
+ description: NEW_DESCRIPTION,
+ updated_at: WRITE_RESPONSE_TS,
+ }),
+ );
+ render(
+
+ (commit = c)} />
+ ,
+ );
+
+ const seen: string[] = [];
+ subscribeField("t-1", "description", (value) => seen.push(value));
+
+ await act(async () => {
+ await commit!("t-1", NEW_DESCRIPTION);
+ });
+
+ expect(getCanonicalValue("t-1", "description")).toBe(NEW_DESCRIPTION);
+ expect(seen).toEqual([NEW_DESCRIPTION]);
+ });
+});
+
+function DescriptionHookProbeWithStore({
+ onReady,
+}: {
+ onReady: (
+ commit: ReturnType,
+ storeApi: ReturnType,
+ ) => void;
+}) {
+ const commit = useCommitTaskDescription();
+ const storeApi = useAppStoreApi();
+ onReady(commit, storeApi);
+ return null;
+}
+
+describe("useCommitTaskDescription stale write response (P1 review fix)", () => {
+ it("does not apply a write response that recordWriteSuccess rejects as stale, once a newer write already superseded it", async () => {
+ seedInitialCanonical("t-1", "description", "Original description", TS);
+ const { Wrapper, state } = makeHarness(baseTask, baseOfficeTask);
+ let commit: ReturnType | null = null;
+ let storeApi: ReturnType | null = null;
+ render(
+
+ ([commit, storeApi] = [c, s])} />
+ ,
+ );
+
+ const first = deferred>();
+ const second = deferred>();
+ mockUpdateTask.mockReturnValueOnce(first.promise);
+ mockUpdateTask.mockReturnValueOnce(second.promise);
+
+ let p1!: Promise<{ ok: true; value: string } | { ok: false }>;
+ let p2!: Promise<{ ok: true; value: string } | { ok: false }>;
+ act(() => {
+ p1 = commit!("t-1", "Draft A");
+ p2 = commit!("t-1", "Draft B");
+ });
+
+ let resultB!: { ok: true; value: string } | { ok: false };
+ await act(async () => {
+ second.resolve(
+ taskResponse({
+ title: baseTask.title,
+ description: "Draft B",
+ updated_at: "2026-05-02T00:00:00Z",
+ }),
+ );
+ resultB = await p2;
+ });
+ expect(resultB).toEqual({ ok: true, value: "Draft B" });
+ expect(state.patches).toEqual([{ description: "Draft B" }]);
+
+ let resultA!: { ok: true; value: string } | { ok: false };
+ await act(async () => {
+ first.resolve(
+ taskResponse({
+ title: baseTask.title,
+ description: "Draft A",
+ updated_at: "2026-05-01T12:00:00Z",
+ }),
+ );
+ resultA = await p1;
+ });
+
+ // Draft A's response is older than Draft B's already-recorded canonical
+ // value, so recordWriteSuccess rejects it (AC-60/AC-62): it must not
+ // apply to the local task or the store, and the hook must report the
+ // still-current canonical value rather than the stale one it saved.
+ expect(state.patches).toEqual([{ description: "Draft B" }]);
+ expect(state.task.description).toBe("Draft B");
+ expect(storeApi!.getState().office.tasks.items[0]?.description).toBe("Draft B");
+ expect(resultA).toEqual({ ok: true, value: "Draft B" });
+ expect(getCanonicalValue("t-1", "description")).toBe("Draft B");
+ });
+});
+
+describe("single-field request body (AC-26)", () => {
+ it("sends only the title field when committing a title, even though the local task has other properties set", async () => {
+ const { Wrapper } = makeHarness(baseTask, baseOfficeTask);
+ let commit: ReturnType | null = null;
+ mockUpdateTask.mockResolvedValueOnce(
+ taskResponse({ title: NEW_TITLE, description: "", updated_at: WRITE_RESPONSE_TS }),
+ );
+ render(
+
+ (commit = c)} />
+ ,
+ );
+
+ await act(async () => {
+ await commit!("t-1", NEW_TITLE);
+ });
+
+ expect(mockUpdateTask).toHaveBeenCalledTimes(1);
+ expect(mockUpdateTask.mock.calls[0]?.[1]).toEqual({ title: NEW_TITLE });
+ });
+
+ it("sends only the description field when committing a description, even though the local task has other properties set", async () => {
+ const { Wrapper } = makeHarness(baseTask, baseOfficeTask);
+ let commit: ReturnType | null = null;
+ mockUpdateTask.mockResolvedValueOnce(
+ taskResponse({
+ title: baseTask.title,
+ description: NEW_DESCRIPTION,
+ updated_at: WRITE_RESPONSE_TS,
+ }),
+ );
+ render(
+
+ (commit = c)} />
+ ,
+ );
+
+ await act(async () => {
+ await commit!("t-1", NEW_DESCRIPTION);
+ });
+
+ expect(mockUpdateTask).toHaveBeenCalledTimes(1);
+ expect(mockUpdateTask.mock.calls[0]?.[1]).toEqual({ description: NEW_DESCRIPTION });
+ });
+});
diff --git a/apps/web/hooks/use-optimistic-task-mutation.ts b/apps/web/hooks/use-optimistic-task-mutation.ts
index a0bdad89c6..c9c21c5cf7 100644
--- a/apps/web/hooks/use-optimistic-task-mutation.ts
+++ b/apps/web/hooks/use-optimistic-task-mutation.ts
@@ -6,6 +6,15 @@ import { useAppStoreApi } from "@/components/state-provider";
import type { Task } from "@/app/office/tasks/[id]/types";
import type { OfficeTask } from "@/lib/state/slices/office/types";
import { t } from "@/lib/i18n";
+import { updateTask } from "@/lib/api/domains/kanban-api";
+import {
+ beginWrite,
+ endWrite,
+ getCanonicalValue,
+ nextTaskSequence,
+ recordWriteSuccess,
+ shouldRestoreAfterFailedWrite,
+} from "@/lib/state/office-task-content-sync";
/**
* Context for the local (page-level) task representation. The office store
@@ -64,13 +73,21 @@ export function useOptimisticTaskMutation() {
try {
await apiCall();
} catch (err) {
- // Rollback both layers.
+ // Rollback both layers. This hook never patches title/description
+ // (see `toOfficeTaskPatch` above), so the rollback must not touch
+ // them either — those two fields are governed exclusively by the
+ // per-field guard in office-task-content-sync.ts and have their own
+ // dedicated writers (useCommitTaskTitle/useCommitTaskDescription).
+ // Restoring the full pre-mutation snapshot here would silently
+ // revert a confirmed title/description edit whenever an unrelated
+ // picker mutation fails (AC-61: exactly two writers may touch a
+ // guarded field).
ctx.restore(snapshot);
if (storeSnapshot) {
- storeApi.getState().patchTaskInStore(taskId, storeSnapshot);
+ const { title: _title, description: _description, ...storeRollback } = storeSnapshot;
+ storeApi.getState().patchTaskInStore(taskId, storeRollback);
}
- const message = err instanceof Error ? err.message : t("task:updateFailed");
- toast.error(message);
+ toastUpdateFailure(err);
throw err;
}
},
@@ -95,3 +112,99 @@ function toOfficeTaskPatch(patch: Partial): Partial {
if (patch.blockedBy !== undefined) out.blockedBy = patch.blockedBy;
return out;
}
+
+function toastUpdateFailure(err: unknown): void {
+ const message = err instanceof Error ? err.message : t("task:updateFailed");
+ toast.error(message);
+}
+
+/**
+ * Commits a title edit (AC-3, AC-9, AC-11, AC-44, AC-53, AC-54, AC-55, AC-64,
+ * AC-65). Optimistic at issue time: the local task and the Office task store
+ * entry are patched with the trimmed draft before the request resolves. On
+ * failure, restores the field's *currently recorded* canonical value — never
+ * a commit-time snapshot — but only when `shouldRestoreAfterFailedWrite`
+ * says this commit is still the "last word standing" for the field.
+ */
+export function useCommitTaskTitle() {
+ const ctx = useTaskOptimisticContext();
+ const storeApi = useAppStoreApi();
+
+ return useCallback(
+ async (taskId: string, trimmedTitle: string): Promise => {
+ const sequence = nextTaskSequence(taskId);
+ beginWrite(taskId, "title", sequence);
+ ctx.applyPatch({ title: trimmedTitle });
+ storeApi.getState().patchTaskInStore(taskId, { title: trimmedTitle });
+
+ try {
+ const updated = await updateTask(taskId, { title: trimmedTitle });
+ recordWriteSuccess(taskId, "title", updated.title, updated.updated_at, sequence);
+ endWrite(taskId, "title", sequence);
+ } catch (err) {
+ const shouldRestore = shouldRestoreAfterFailedWrite(taskId, "title", sequence);
+ endWrite(taskId, "title", sequence);
+ if (shouldRestore) {
+ const canonical = getCanonicalValue(taskId, "title");
+ if (canonical !== undefined) {
+ ctx.applyPatch({ title: canonical });
+ storeApi.getState().patchTaskInStore(taskId, { title: canonical });
+ }
+ }
+ toastUpdateFailure(err);
+ }
+ },
+ [ctx, storeApi],
+ );
+}
+
+/**
+ * Commits a description save (AC-16, AC-17, AC-18, AC-42, AC-56). Not
+ * optimistic: neither the local task nor the Office task store entry is
+ * patched until the write succeeds, and a failure needs no rollback because
+ * nothing was applied. The caller (the description editor) is responsible
+ * for AC-17/AC-42's "did the draft change while the save was in flight"
+ * comparison against the returned value.
+ */
+export function useCommitTaskDescription() {
+ const ctx = useTaskOptimisticContext();
+ const storeApi = useAppStoreApi();
+
+ return useCallback(
+ async (
+ taskId: string,
+ trimmedDescription: string,
+ ): Promise<{ ok: true; value: string } | { ok: false }> => {
+ const sequence = nextTaskSequence(taskId);
+ beginWrite(taskId, "description", sequence);
+
+ try {
+ const updated = await updateTask(taskId, { description: trimmedDescription });
+ const accepted = recordWriteSuccess(
+ taskId,
+ "description",
+ updated.description,
+ updated.updated_at,
+ sequence,
+ );
+ endWrite(taskId, "description", sequence);
+ if (!accepted) {
+ // A newer write or refetch already superseded this response
+ // (AC-60/AC-62): applying it here would show a stale description.
+ // The already-recorded newer value reaches the page/store via the
+ // subscribeField deferred-apply listener once the guard clears.
+ const canonical = getCanonicalValue(taskId, "description") ?? updated.description;
+ return { ok: true, value: canonical };
+ }
+ ctx.applyPatch({ description: updated.description });
+ storeApi.getState().patchTaskInStore(taskId, { description: updated.description });
+ return { ok: true, value: updated.description };
+ } catch (err) {
+ endWrite(taskId, "description", sequence);
+ toastUpdateFailure(err);
+ return { ok: false };
+ }
+ },
+ [ctx, storeApi],
+ );
+}
diff --git a/apps/web/lib/state/office-task-content-sync.test.ts b/apps/web/lib/state/office-task-content-sync.test.ts
new file mode 100644
index 0000000000..2b8201593d
--- /dev/null
+++ b/apps/web/lib/state/office-task-content-sync.test.ts
@@ -0,0 +1,532 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import {
+ __resetOfficeTaskContentSyncForTests,
+ beginWrite,
+ closeFieldEditor,
+ endAllEditorsForTask,
+ endWrite,
+ getCanonicalValue,
+ isFieldGuarded,
+ nextTaskSequence,
+ openFieldEditor,
+ parseUpdatedAt,
+ recordRefetchCandidate,
+ recordWriteSuccess,
+ seedInitialCanonical,
+ shouldRestoreAfterFailedWrite,
+ subscribeField,
+} from "./office-task-content-sync";
+
+const TASK = "task-1";
+const T10 = "2026-08-20T10:00:00Z";
+const T11 = "2026-08-20T11:00:00Z";
+const T12 = "2026-08-20T12:00:00Z";
+
+beforeEach(() => {
+ __resetOfficeTaskContentSyncForTests();
+});
+
+describe("parseUpdatedAt", () => {
+ it("parses the SQLite space-separated offset-bearing layout to nanosecond precision (AC-66)", () => {
+ const ns = parseUpdatedAt("2026-08-20 14:30:05.123456789+00:00");
+ const wholeSecondMs = Date.parse("2026-08-20T14:30:05+00:00");
+ expect(ns).toBe(BigInt(wholeSecondMs) * BigInt(1_000_000) + BigInt(123456789));
+ });
+
+ it("parses the RFC3339Nano T/Z layout emitted by a write response to nanosecond precision (AC-66)", () => {
+ const ns = parseUpdatedAt("2026-08-20T14:30:05.123456789Z");
+ const wholeSecondMs = Date.parse("2026-08-20T14:30:05Z");
+ expect(ns).toBe(BigInt(wholeSecondMs) * BigInt(1_000_000) + BigInt(123456789));
+ });
+
+ it("treats the space-separated form's offset as authoritative, matching its T/Z equivalent", () => {
+ const spaceForm = parseUpdatedAt("2026-08-20 14:30:05.123456789+00:00");
+ const tForm = parseUpdatedAt("2026-08-20T14:30:05.123456789Z");
+ expect(spaceForm).toBe(tForm);
+ });
+
+ it("resolves the space-separated form via its own embedded offset, not the host machine's local timezone (AC-66)", () => {
+ // Every other space-separated fixture above uses a +00:00 offset, which
+ // coincidentally equals local time on a UTC-TZ test runner and would not
+ // catch a regression to `new Date(rawSpaceSeparatedString)` — the spec
+ // explicitly calls out that browsers disagree on how (or whether) to
+ // parse that non-ISO-8601 form, with some falling back to local time.
+ // A non-zero offset makes the correct answer distinguishable from any
+ // such fallback regardless of what timezone the test happens to run in.
+ const ns = parseUpdatedAt("2026-08-20 14:30:05.123456789+05:30");
+ // 14:30:05+05:30 is 09:00:05Z — computed via an explicit offset, so this
+ // expected value is itself timezone-independent.
+ const wholeSecondMs = Date.parse("2026-08-20T09:00:05+00:00");
+ expect(ns).toBe(BigInt(wholeSecondMs) * BigInt(1_000_000) + BigInt(123456789));
+ });
+
+ it("does not collapse two distinct sub-millisecond instants to the same value", () => {
+ // AC-60/62 requires strict ordering between these two: both round to the
+ // same millisecond under Date.parse (123ms), but differ at the
+ // microsecond digit — a millisecond-truncating parse would wrongly
+ // report them equal.
+ const earlier = parseUpdatedAt("2026-08-20T14:30:05.123400000Z");
+ const later = parseUpdatedAt("2026-08-20T14:30:05.123500000Z");
+ expect(earlier).not.toBe(later);
+ expect(earlier! < later!).toBe(true);
+ });
+
+ it("returns null for an unparseable value (AC-67 candidate)", () => {
+ expect(parseUpdatedAt("not-a-date")).toBeNull();
+ expect(parseUpdatedAt("")).toBeNull();
+ expect(parseUpdatedAt(null)).toBeNull();
+ expect(parseUpdatedAt(undefined)).toBeNull();
+ });
+});
+
+describe("seedInitialCanonical (AC-59, AC-72)", () => {
+ it("records the first candidate unconditionally, even if its updated_at is unparseable", () => {
+ seedInitialCanonical(TASK, "title", "Hello", "garbage");
+ expect(getCanonicalValue(TASK, "title")).toBe("Hello");
+ });
+
+ it("a same-session revisit re-seed overwrites a stale canonical with a newer value (AC-59, AC-64)", () => {
+ // A revisit's fresh initial-load seed must be able to supersede a
+ // canonical value recorded by an earlier visit in the same SPA session —
+ // otherwise a later failed commit's restore (AC-9/AC-64, which reads the
+ // canonical value live at restore time) could resurrect a value the
+ // server has already moved past instead of the value most recently
+ // loaded from it.
+ seedInitialCanonical(TASK, "title", "Hello", T10);
+ seedInitialCanonical(TASK, "title", "Newer on revisit", T11);
+ expect(getCanonicalValue(TASK, "title")).toBe("Newer on revisit");
+ });
+
+ it("a revisit re-seed does not resurrect a stale value once a newer one is canonical", () => {
+ // The revisit seed goes through the same AC-60/62 ordering as any other
+ // candidate — it is not a blind overwrite, just no longer a no-op.
+ seedInitialCanonical(TASK, "title", "Hello", T11);
+ seedInitialCanonical(TASK, "title", "Stale revisit", T10);
+ expect(getCanonicalValue(TASK, "title")).toBe("Hello");
+ });
+
+ it("normalises an absent description to the empty string (AC-71)", () => {
+ seedInitialCanonical(TASK, "description", undefined as unknown as string, T10);
+ expect(getCanonicalValue(TASK, "description")).toBe("");
+ });
+
+ it("records the first candidate unconditionally even when its sequence predates the field's last write (AC-72 overrides AC-52)", () => {
+ // AC-72: until a field has a recorded canonical value, the seed rule
+ // exempts the next candidate from every other rule, including the AC-52
+ // staleness check — that check compares against a write, but there is
+ // nothing yet to be stale relative to. This reproduces the initial page
+ // load's seed call landing after a title commit was already issued
+ // (e.g. the authoritative GET resolves late, or is retried, while the
+ // page already rendered the task interactively from a store cache).
+ const staleSeq = nextTaskSequence(TASK);
+ const writeSeq = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", writeSeq);
+
+ const accepted = recordRefetchCandidate(TASK, "title", "first ever value", T10, staleSeq);
+
+ expect(accepted).toBe(true);
+ expect(getCanonicalValue(TASK, "title")).toBe("first ever value");
+ });
+});
+
+describe("recordRefetchCandidate ordering (AC-60, AC-62, AC-63, AC-73)", () => {
+ it("discards a strictly older candidate (AC-60)", () => {
+ seedInitialCanonical(TASK, "title", "v1", T10);
+ const accepted = recordRefetchCandidate(
+ TASK,
+ "title",
+ "older",
+ "2026-08-20T09:00:00Z",
+ nextTaskSequence(TASK),
+ );
+ expect(accepted).toBe(false);
+ expect(getCanonicalValue(TASK, "title")).toBe("v1");
+ });
+
+ it("accepts a strictly newer candidate", () => {
+ seedInitialCanonical(TASK, "title", "v1", T10);
+ const accepted = recordRefetchCandidate(TASK, "title", "newer", T11, nextTaskSequence(TASK));
+ expect(accepted).toBe(true);
+ expect(getCanonicalValue(TASK, "title")).toBe("newer");
+ });
+
+ it("breaks an equal-instant tie by request-issue sequence, higher sequence wins (AC-63)", () => {
+ seedInitialCanonical(TASK, "title", "v1", T10);
+ const loserSeq = nextTaskSequence(TASK);
+ const winnerSeq = nextTaskSequence(TASK);
+ // Winner recorded first, loser arrives after but carries a lower sequence.
+ expect(recordRefetchCandidate(TASK, "title", "winner", T10, winnerSeq)).toBe(true);
+ expect(getCanonicalValue(TASK, "title")).toBe("winner");
+ expect(recordRefetchCandidate(TASK, "title", "loser", T10, loserSeq)).toBe(false);
+ expect(getCanonicalValue(TASK, "title")).toBe("winner");
+ });
+
+ it("discards a candidate whose updated_at cannot be parsed (AC-67)", () => {
+ seedInitialCanonical(TASK, "title", "v1", T10);
+ const accepted = recordRefetchCandidate(
+ TASK,
+ "title",
+ "bad",
+ "garbage",
+ nextTaskSequence(TASK),
+ );
+ expect(accepted).toBe(false);
+ expect(getCanonicalValue(TASK, "title")).toBe("v1");
+ });
+
+ it("an unparseable seed loses to any later candidate that does parse (AC-73)", () => {
+ seedInitialCanonical(TASK, "title", "seed", "garbage");
+ const accepted = recordRefetchCandidate(
+ TASK,
+ "title",
+ "supersedes",
+ T10,
+ nextTaskSequence(TASK),
+ );
+ expect(accepted).toBe(true);
+ expect(getCanonicalValue(TASK, "title")).toBe("supersedes");
+ });
+
+ it("discards a stale SQLite-layout refetch that lands within the same millisecond as an already-recorded RFC3339Nano write (AC-60/62/66)", () => {
+ // Both instants round to the same millisecond, so a millisecond-precision
+ // comparison would wrongly treat them as an equal-instant tie (or worse,
+ // accept the stale one outright) instead of correctly ordering them.
+ seedInitialCanonical(TASK, "title", "seed", T10);
+ const writeSeq = nextTaskSequence(TASK);
+ expect(
+ recordWriteSuccess(TASK, "title", "from write", "2026-08-20T14:30:05.123500000Z", writeSeq),
+ ).toBe(true);
+ endWrite(TASK, "title", writeSeq);
+
+ const refetchSeq = nextTaskSequence(TASK);
+ const accepted = recordRefetchCandidate(
+ TASK,
+ "title",
+ "from stale refetch",
+ "2026-08-20 14:30:05.123400000+00:00",
+ refetchSeq,
+ );
+ expect(accepted).toBe(false);
+ expect(getCanonicalValue(TASK, "title")).toBe("from write");
+ });
+
+ it("accepts an SQLite-layout refetch that truly postdates an RFC3339Nano write by sub-millisecond microseconds (AC-60/62/66)", () => {
+ seedInitialCanonical(TASK, "title", "seed", T10);
+ const writeSeq = nextTaskSequence(TASK);
+ expect(
+ recordWriteSuccess(TASK, "title", "from write", "2026-08-20T14:30:05.123400000Z", writeSeq),
+ ).toBe(true);
+ endWrite(TASK, "title", writeSeq);
+
+ const refetchSeq = nextTaskSequence(TASK);
+ const accepted = recordRefetchCandidate(
+ TASK,
+ "title",
+ "from later refetch",
+ "2026-08-20 14:30:05.123500000+00:00",
+ refetchSeq,
+ );
+ expect(accepted).toBe(true);
+ expect(getCanonicalValue(TASK, "title")).toBe("from later refetch");
+ });
+});
+
+describe("recordRefetchCandidate staleness vs a committed write (AC-52)", () => {
+ it("discards a refetch whose request predates this client's most recent write to the field", () => {
+ seedInitialCanonical(TASK, "title", "v1", T10);
+ const refetchSeq = nextTaskSequence(TASK);
+ const writeSeq = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", writeSeq);
+
+ const accepted = recordRefetchCandidate(TASK, "title", "stale", T12, refetchSeq);
+ expect(accepted).toBe(false);
+ expect(getCanonicalValue(TASK, "title")).toBe("v1");
+ });
+
+ it("does not apply write-staleness to a write's own response (AC-52 scoped to refetch only)", () => {
+ seedInitialCanonical(TASK, "title", "v1", T10);
+ const writeSeq = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", writeSeq);
+ // A later write is also issued for the same field, making this write's
+ // sequence no longer "most recent" — its own response must still record.
+ const laterWriteSeq = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", laterWriteSeq);
+
+ const accepted = recordWriteSuccess(TASK, "title", "first-write-result", T11, writeSeq);
+ expect(accepted).toBe(true);
+ });
+
+ it("still records a non-stale refetch while a write to that field is in flight (AC-49)", () => {
+ seedInitialCanonical(TASK, "title", "v1", T10);
+ const writeSeq = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", writeSeq);
+ const refetchSeq = nextTaskSequence(TASK);
+
+ const accepted = recordRefetchCandidate(TASK, "title", "v2-from-refetch", T11, refetchSeq);
+
+ expect(accepted).toBe(true);
+ expect(getCanonicalValue(TASK, "title")).toBe("v2-from-refetch");
+ // The write is still in flight, so the field remains guarded: recording
+ // succeeded, but nothing was applied to the displayed value yet (AC-49
+ // scoped to recording; AC-50 governs the deferred apply on release).
+ expect(isFieldGuarded(TASK, "title")).toBe(true);
+ });
+});
+
+describe("guard derivation (AC-28, AC-38, AC-68, AC-69, AC-70)", () => {
+ it("is unguarded by default", () => {
+ expect(isFieldGuarded(TASK, "title")).toBe(false);
+ });
+
+ it("is guarded while an editor is open", () => {
+ openFieldEditor(TASK, "title");
+ expect(isFieldGuarded(TASK, "title")).toBe(true);
+ closeFieldEditor(TASK, "title");
+ expect(isFieldGuarded(TASK, "title")).toBe(false);
+ });
+
+ it("is guarded while a write is in flight, independent of editor state", () => {
+ const seq = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", seq);
+ expect(isFieldGuarded(TASK, "title")).toBe(true);
+ endWrite(TASK, "title", seq);
+ expect(isFieldGuarded(TASK, "title")).toBe(false);
+ });
+
+ it("stays guarded until both the editor closes and the write resolves", () => {
+ openFieldEditor(TASK, "title");
+ const seq = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", seq);
+ closeFieldEditor(TASK, "title");
+ expect(isFieldGuarded(TASK, "title")).toBe(true);
+ endWrite(TASK, "title", seq);
+ expect(isFieldGuarded(TASK, "title")).toBe(false);
+ });
+
+ it("endAllEditorsForTask ends only the open-editor contribution, not pending writes (AC-68)", () => {
+ openFieldEditor(TASK, "title");
+ const seq = nextTaskSequence(TASK);
+ beginWrite(TASK, "description", seq);
+ openFieldEditor(TASK, "description");
+
+ endAllEditorsForTask(TASK);
+
+ expect(isFieldGuarded(TASK, "title")).toBe(false);
+ expect(isFieldGuarded(TASK, "description")).toBe(true); // write still pending
+ endWrite(TASK, "description", seq);
+ expect(isFieldGuarded(TASK, "description")).toBe(false);
+ });
+
+ it("a write resolving after unmount still records canonical and clears its guard (AC-69)", () => {
+ const seq = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", seq);
+ endAllEditorsForTask(TASK); // simulate unmount; write is still in flight
+
+ const accepted = recordWriteSuccess(TASK, "title", "post-unmount", T12, seq);
+ expect(accepted).toBe(true);
+ endWrite(TASK, "title", seq);
+ expect(isFieldGuarded(TASK, "title")).toBe(false);
+ expect(getCanonicalValue(TASK, "title")).toBe("post-unmount");
+ });
+
+ it("ends a write's guard contribution the same way on failure as on success (AC-70)", () => {
+ const seq = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", seq);
+ // Failure path: caller does NOT call recordWriteSuccess, only endWrite.
+ endWrite(TASK, "title", seq);
+ expect(isFieldGuarded(TASK, "title")).toBe(false);
+ });
+});
+
+describe("shouldRestoreAfterFailedWrite (AC-9, AC-44, AC-53, R5-F6)", () => {
+ it("restores when no other commit to the field is pending or already succeeded", () => {
+ const seq = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", seq);
+ expect(shouldRestoreAfterFailedWrite(TASK, "title", seq)).toBe(true);
+ });
+
+ it("suppresses restore when a later-sequenced commit already succeeded (AC-44)", () => {
+ const failingSeq = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", failingSeq);
+ const laterSeq = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", laterSeq);
+ recordWriteSuccess(TASK, "title", "later-wins", T11, laterSeq);
+ endWrite(TASK, "title", laterSeq);
+
+ expect(shouldRestoreAfterFailedWrite(TASK, "title", failingSeq)).toBe(false);
+ });
+
+ it("suppresses restore when a later-sequenced commit is still unresolved (AC-53)", () => {
+ const failingSeq = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", failingSeq);
+ const laterSeq = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", laterSeq);
+
+ expect(shouldRestoreAfterFailedWrite(TASK, "title", failingSeq)).toBe(false);
+ });
+
+ it("does not suppress restore because of an earlier-sequenced commit, resolved or not (R5-F6)", () => {
+ const earlierSeq = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", earlierSeq); // still unresolved
+ const failingSeq = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", failingSeq);
+
+ expect(shouldRestoreAfterFailedWrite(TASK, "title", failingSeq)).toBe(true);
+ });
+
+ it("does not suppress restore when a later-sequenced commit also resolves with failure (AC-65)", () => {
+ const failingSeq = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", failingSeq);
+ const laterSeq = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", laterSeq);
+ // The later commit also fails: caller does not call recordWriteSuccess, only endWrite.
+ endWrite(TASK, "title", laterSeq);
+
+ expect(shouldRestoreAfterFailedWrite(TASK, "title", failingSeq)).toBe(true);
+ });
+
+ it("chained double failure: earlier commit resolves first, restore is suppressed then completed by the later failure (AC-65)", () => {
+ // This module never mutates canonical on failure — recordWriteSuccess is
+ // the only writer, and it is deliberately never called here (both
+ // commits fail). The two shouldRestoreAfterFailedWrite return values
+ // below are the actual assertions; the real "does the displayed title
+ // get restored to canonical" behavior is exercised end-to-end (via
+ // useCommitTaskTitle's actual restore action) in
+ // use-optimistic-task-mutation.test.tsx's "chained double failure" tests.
+ seedInitialCanonical(TASK, "title", "original", T10);
+ const earlySeq = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", earlySeq);
+ const laterSeq = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", laterSeq);
+
+ expect(shouldRestoreAfterFailedWrite(TASK, "title", earlySeq)).toBe(false); // AC-53: later still pending
+ endWrite(TASK, "title", earlySeq);
+
+ expect(shouldRestoreAfterFailedWrite(TASK, "title", laterSeq)).toBe(true); // nothing left pending
+ endWrite(TASK, "title", laterSeq);
+ });
+
+ it("chained double failure: later commit resolves first, both restores fire, canonical unaffected (AC-65)", () => {
+ // See the comment on the previous test: this only exercises the boolean
+ // query, not an actual restore. The end-to-end restore-to-canonical
+ // behavior lives in use-optimistic-task-mutation.test.tsx.
+ seedInitialCanonical(TASK, "title", "original", T10);
+ const earlySeq = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", earlySeq);
+ const laterSeq = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", laterSeq);
+
+ expect(shouldRestoreAfterFailedWrite(TASK, "title", laterSeq)).toBe(true); // nothing later pending
+ endWrite(TASK, "title", laterSeq);
+
+ expect(shouldRestoreAfterFailedWrite(TASK, "title", earlySeq)).toBe(true); // the later write already resolved
+ endWrite(TASK, "title", earlySeq);
+ });
+});
+
+describe("recordWriteSuccess ordering relative to guard release (AC-58)", () => {
+ it("records the canonical value while the write's guard contribution is still held, before the caller releases it", () => {
+ seedInitialCanonical(TASK, "title", "v1", T10);
+ const seq = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", seq);
+
+ recordWriteSuccess(TASK, "title", "v2", T11, seq);
+
+ // Canonical must already reflect the persisted value even though the
+ // write's guard contribution has not been released yet (endWrite has not
+ // run) — AC-58 requires recording strictly before the guard releases,
+ // not merely "eventually" once it does.
+ expect(getCanonicalValue(TASK, "title")).toBe("v2");
+ expect(isFieldGuarded(TASK, "title")).toBe(true);
+
+ endWrite(TASK, "title", seq);
+ expect(isFieldGuarded(TASK, "title")).toBe(false);
+ });
+});
+
+describe("convergence after overlapping successful title commits (AC-54)", () => {
+ it("converges on the later-persisted title when the earlier commit's response is recorded second", () => {
+ const seen: string[] = [];
+ seedInitialCanonical(TASK, "title", "original", T10);
+ subscribeField(TASK, "title", (value) => seen.push(value));
+
+ const seqA = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", seqA);
+ const seqB = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", seqB);
+
+ // B was issued after A, but its server response is recorded first while
+ // A is still in flight, so the field stays guarded and nothing notifies yet.
+ recordWriteSuccess(TASK, "title", "B-title", T11, seqB);
+ endWrite(TASK, "title", seqB);
+ expect(getCanonicalValue(TASK, "title")).toBe("B-title");
+ expect(seen).toEqual([]);
+
+ // A's response arrives second, carrying a later updated_at (it was
+ // persisted after B on the server) and releases the guard.
+ recordWriteSuccess(TASK, "title", "A-title", T12, seqA);
+ endWrite(TASK, "title", seqA);
+
+ expect(getCanonicalValue(TASK, "title")).toBe("A-title");
+ expect(seen).toEqual(["A-title"]);
+ });
+
+ it("converges on the same later-persisted title regardless of which response is recorded first", () => {
+ seedInitialCanonical(TASK, "title", "original", T10);
+
+ const seqA = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", seqA);
+ const seqB = nextTaskSequence(TASK);
+ beginWrite(TASK, "title", seqB);
+
+ // This time the earlier-issued commit's response, carrying the earlier
+ // updated_at, is recorded first.
+ recordWriteSuccess(TASK, "title", "A-title", T11, seqA);
+ endWrite(TASK, "title", seqA);
+ recordWriteSuccess(TASK, "title", "B-title", T12, seqB);
+ endWrite(TASK, "title", seqB);
+
+ expect(getCanonicalValue(TASK, "title")).toBe("B-title");
+ });
+});
+
+describe("deferred-apply notification (AC-27, AC-50)", () => {
+ it("notifies immediately when a candidate is accepted while unguarded", () => {
+ const seen: string[] = [];
+ subscribeField(TASK, "title", (value) => seen.push(value));
+ seedInitialCanonical(TASK, "title", "v1", T10);
+ recordRefetchCandidate(TASK, "title", "v2", T11, nextTaskSequence(TASK));
+ expect(seen).toEqual(["v1", "v2"]);
+ });
+
+ it("defers notification while guarded, then fires once the guard releases", () => {
+ const seen: string[] = [];
+ seedInitialCanonical(TASK, "title", "v1", T10);
+ subscribeField(TASK, "title", (value) => seen.push(value));
+
+ openFieldEditor(TASK, "title");
+ recordRefetchCandidate(TASK, "title", "v2", T11, nextTaskSequence(TASK));
+ expect(seen).toEqual([]); // guarded: no notification yet
+
+ closeFieldEditor(TASK, "title");
+ expect(seen).toEqual(["v2"]);
+ });
+
+ it("unsubscribe stops further notifications", () => {
+ const seen: string[] = [];
+ const unsubscribe = subscribeField(TASK, "title", (value) => seen.push(value));
+ seedInitialCanonical(TASK, "title", "v1", T10);
+ unsubscribe();
+ recordRefetchCandidate(TASK, "title", "v2", T11, nextTaskSequence(TASK));
+ expect(seen).toEqual(["v1"]);
+ });
+});
+
+describe("multi-task isolation", () => {
+ it("does not leak canonical state between tasks", () => {
+ seedInitialCanonical("task-a", "title", "A", T10);
+ seedInitialCanonical("task-b", "title", "B", T10);
+ expect(getCanonicalValue("task-a", "title")).toBe("A");
+ expect(getCanonicalValue("task-b", "title")).toBe("B");
+ });
+});
diff --git a/apps/web/lib/state/office-task-content-sync.ts b/apps/web/lib/state/office-task-content-sync.ts
new file mode 100644
index 0000000000..df39c875c6
--- /dev/null
+++ b/apps/web/lib/state/office-task-content-sync.ts
@@ -0,0 +1,326 @@
+/**
+ * Canonical-value / in-flight-guard concurrency protocol for Office task
+ * title + description editing (docs/specs/office-task-content-editing/spec.md,
+ * section E). Framework-agnostic, module-level singleton state per task.
+ *
+ * Two structures are kept deliberately separate (see the task plan's R5-F4
+ * record): `canonicalByTaskField` never loses a value when a field becomes
+ * unguarded, while `guardByTaskField` entries are garbage-collected as soon
+ * as a field has neither an open editor nor an unresolved write.
+ */
+
+export type SyncField = "title" | "description";
+type CandidateKind = "refetch" | "write";
+
+type CanonicalRecord = {
+ value: string;
+ updatedAtNs: bigint | null;
+ sequence: number;
+};
+
+type GuardState = {
+ editorOpen: boolean;
+ pendingWrites: Set;
+};
+
+const FIELDS: SyncField[] = ["title", "description"];
+
+const canonicalByTaskField = new Map>();
+const guardByTaskField = new Map>();
+const sequenceByTask = new Map();
+const lastWriteIssueSequenceByTaskField = new Map>();
+const lastSuccessfulWriteSequenceByTaskField = new Map>();
+const listenersByTaskField = new Map void>>>();
+
+function fieldMap(store: Map>, taskId: string): Map {
+ let m = store.get(taskId);
+ if (!m) {
+ m = new Map();
+ store.set(taskId, m);
+ }
+ return m;
+}
+
+/** Monotonic per-task counter; call at request-issue time (refetch or write). */
+export function nextTaskSequence(taskId: string): number {
+ const next = (sequenceByTask.get(taskId) ?? 0) + 1;
+ sequenceByTask.set(taskId, next);
+ return next;
+}
+
+function normalizeValue(raw: string | null | undefined): string {
+ // AC-71: description normalises an absent value to "". Title's DTO
+ // declares it required, but applying the same fallback to both is harmless.
+ return raw ?? "";
+}
+
+const DATETIME_LAYOUT =
+ /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
+
+/**
+ * Parses both `updated_at` layouts in play (AC-66): the space-separated,
+ * offset-bearing SQLite bind layout, and RFC3339Nano. Returns nanoseconds
+ * since the Unix epoch as a bigint, not `Date.parse`'s millisecond number —
+ * `Date.parse` truncates any fractional-second precision past milliseconds,
+ * which would silently defeat AC-60/62's strict ordering guarantee for two
+ * candidates issued within the same millisecond (exactly what a refetch
+ * racing a write's own response produces). The fractional-second digits are
+ * parsed directly rather than handed to `Date.parse`, whose input must not
+ * carry the space-separated form as-is since browsers disagree on how (or
+ * whether) to interpret it.
+ */
+export function parseUpdatedAt(raw: string | null | undefined): bigint | null {
+ if (!raw) return null;
+ const match = DATETIME_LAYOUT.exec(raw.trim());
+ if (!match) return null;
+ const [, year, month, day, hour, minute, second, frac = "", offset] = match;
+ const wholeSecondMs = Date.parse(`${year}-${month}-${day}T${hour}:${minute}:${second}${offset}`);
+ if (Number.isNaN(wholeSecondMs)) return null;
+ const nanoDigits = `${frac.slice(1)}000000000`.slice(0, 9);
+ return BigInt(wholeSecondMs) * BigInt(1_000_000) + BigInt(nanoDigits);
+}
+
+export function isFieldGuarded(taskId: string, field: SyncField): boolean {
+ const g = guardByTaskField.get(taskId)?.get(field);
+ return !!g && (g.editorOpen || g.pendingWrites.size > 0);
+}
+
+export function getCanonicalValue(taskId: string, field: SyncField): string | undefined {
+ return canonicalByTaskField.get(taskId)?.get(field)?.value;
+}
+
+function notifyField(taskId: string, field: SyncField): void {
+ const value = getCanonicalValue(taskId, field);
+ if (value === undefined) return;
+ const listeners = listenersByTaskField.get(taskId)?.get(field);
+ if (!listeners || listeners.size === 0) return;
+ listeners.forEach((listener) => listener(value));
+}
+
+/**
+ * Subscribes to a field's deferred-apply notifications (AC-27/AC-50). Fired
+ * whenever a candidate is accepted while the field is unguarded, and whenever
+ * the field transitions from guarded to unguarded. The listener receives the
+ * current canonical value and is responsible for its own "if the two differ"
+ * comparison against whatever it currently displays.
+ */
+export function subscribeField(
+ taskId: string,
+ field: SyncField,
+ listener: (value: string) => void,
+): () => void {
+ const tm = fieldMap(listenersByTaskField, taskId);
+ const set = tm.get(field) ?? new Set<(value: string) => void>();
+ set.add(listener);
+ tm.set(field, set);
+ return () => {
+ set.delete(listener);
+ };
+}
+
+/**
+ * Records a candidate canonical value for a field. Implements the seed rule
+ * (AC-72), the parse-failure discard (AC-67), the unknown-instant-loses rule
+ * (AC-73), the strict-ordering discard (AC-60/AC-62), the equal-instant
+ * tiebreak by request-issue sequence (AC-63), and — for refetch candidates
+ * only — the write-predates-refetch staleness discard (AC-52). Returns
+ * whether the candidate was accepted. On acceptance, fires a deferred-apply
+ * notification immediately if the field is currently unguarded; otherwise the
+ * eventual guard release will notify once the guard clears (AC-50).
+ */
+type Candidate = {
+ rawValue: string | null | undefined;
+ updatedAtRaw: string | null | undefined;
+ sequence: number;
+ kind: CandidateKind;
+};
+
+function recordCandidate(taskId: string, field: SyncField, candidate: Candidate): boolean {
+ const { rawValue, updatedAtRaw, sequence, kind } = candidate;
+ const canonicalMap = fieldMap(canonicalByTaskField, taskId);
+ const current = canonicalMap.get(field);
+ const value = normalizeValue(rawValue);
+ const candidateNs = parseUpdatedAt(updatedAtRaw);
+
+ // AC-72: until a field has a recorded canonical value, this is a seed and
+ // every other rule below (including AC-52's staleness check) is exempted —
+ // there is nothing yet to be stale, out of order, or unparseable relative to.
+ if (current && kind === "refetch") {
+ const lastWriteSeq = lastWriteIssueSequenceByTaskField.get(taskId)?.get(field);
+ if (lastWriteSeq !== undefined && sequence < lastWriteSeq) {
+ return false; // AC-52
+ }
+ }
+
+ if (current) {
+ if (candidateNs === null) {
+ return false; // AC-67
+ }
+ if (current.updatedAtNs !== null) {
+ if (candidateNs < current.updatedAtNs) return false; // AC-60 / AC-62
+ if (candidateNs === current.updatedAtNs && sequence <= current.sequence) return false; // AC-63 loser
+ }
+ // Either candidateNs > current.updatedAtNs, or candidateNs === current.updatedAtNs
+ // with a higher sequence, or current.updatedAtNs was unparseable (AC-73).
+ }
+
+ canonicalMap.set(field, { value, updatedAtNs: candidateNs, sequence });
+ if (!isFieldGuarded(taskId, field)) {
+ notifyField(taskId, field);
+ }
+ return true;
+}
+
+/**
+ * AC-59: seeds a field's canonical value on initial page load. The first-ever
+ * call for a (taskId, field) is an unconditional AC-72 seed; a same-session
+ * revisit's call goes through `recordCandidate`'s normal ordering rules like
+ * any other candidate, so a fresh load can supersede a stale canonical value
+ * left over from an earlier visit instead of being silently dropped.
+ */
+export function seedInitialCanonical(
+ taskId: string,
+ field: SyncField,
+ value: string,
+ updatedAtRaw: string | null | undefined,
+): void {
+ recordCandidate(taskId, field, {
+ rawValue: value,
+ updatedAtRaw,
+ sequence: nextTaskSequence(taskId),
+ kind: "refetch",
+ });
+}
+
+/** AC-49: records a non-stale refetch's value as canonical for a field. */
+export function recordRefetchCandidate(
+ taskId: string,
+ field: SyncField,
+ rawValue: string | null | undefined,
+ updatedAtRaw: string | null | undefined,
+ sequence: number,
+): boolean {
+ return recordCandidate(taskId, field, { rawValue, updatedAtRaw, sequence, kind: "refetch" });
+}
+
+/**
+ * AC-58: records a successful write's own response value as canonical.
+ * Must be called before the caller releases the write's guard contribution
+ * via `endWrite` (R5-F3: a failed write must never call this).
+ */
+export function recordWriteSuccess(
+ taskId: string,
+ field: SyncField,
+ rawValue: string | null | undefined,
+ updatedAtRaw: string | null | undefined,
+ sequence: number,
+): boolean {
+ const fm = fieldMap(lastSuccessfulWriteSequenceByTaskField, taskId);
+ const current = fm.get(field);
+ if (current === undefined || sequence > current) {
+ fm.set(field, sequence);
+ }
+ return recordCandidate(taskId, field, { rawValue, updatedAtRaw, sequence, kind: "write" });
+}
+
+/**
+ * AC-44/AC-53: decides whether a failed write should restore the field's
+ * canonical value. Must be called before `endWrite` removes this write's own
+ * sequence from the in-flight set. Restore is suppressed when a *later*
+ * (higher-sequence) commit to the same field has already succeeded (AC-44) or
+ * is still unresolved (AC-53) — an earlier-sequenced commit in either state
+ * does not suppress it (R5-F6).
+ */
+export function shouldRestoreAfterFailedWrite(
+ taskId: string,
+ field: SyncField,
+ sequence: number,
+): boolean {
+ const lastSuccess = lastSuccessfulWriteSequenceByTaskField.get(taskId)?.get(field);
+ if (lastSuccess !== undefined && lastSuccess > sequence) return false; // AC-44
+
+ const pending = guardByTaskField.get(taskId)?.get(field)?.pendingWrites;
+ if (pending) {
+ for (const pendingSeq of pending) {
+ if (pendingSeq > sequence) return false; // AC-53
+ }
+ }
+ return true;
+}
+
+function releaseIfUnguarded(
+ taskId: string,
+ field: SyncField,
+ gm: Map,
+ guard: GuardState,
+): void {
+ if (!guard.editorOpen && guard.pendingWrites.size === 0) {
+ gm.delete(field);
+ notifyField(taskId, field);
+ }
+}
+
+/** AC-28/AC-38: an open editor is one of the two guard facts. */
+export function openFieldEditor(taskId: string, field: SyncField): void {
+ const gm = fieldMap(guardByTaskField, taskId);
+ const guard = gm.get(field) ?? { editorOpen: false, pendingWrites: new Set() };
+ guard.editorOpen = true;
+ gm.set(field, guard);
+}
+
+/** Closing the editor by any route (commit, cancel, blur) ends its guard contribution. */
+export function closeFieldEditor(taskId: string, field: SyncField): void {
+ const gm = guardByTaskField.get(taskId);
+ const guard = gm?.get(field);
+ if (!gm || !guard) return;
+ guard.editorOpen = false;
+ releaseIfUnguarded(taskId, field, gm, guard);
+}
+
+/**
+ * Registers an in-flight write's guard contribution and records this write
+ * as the field's most-recently-issued write for AC-52 staleness comparisons.
+ */
+export function beginWrite(taskId: string, field: SyncField, sequence: number): void {
+ const gm = fieldMap(guardByTaskField, taskId);
+ const guard = gm.get(field) ?? { editorOpen: false, pendingWrites: new Set() };
+ guard.pendingWrites.add(sequence);
+ gm.set(field, guard);
+ fieldMap(lastWriteIssueSequenceByTaskField, taskId).set(field, sequence);
+}
+
+/** AC-70: ends a write's guard contribution identically whether it succeeded or failed. */
+export function endWrite(taskId: string, field: SyncField, sequence: number): void {
+ const gm = guardByTaskField.get(taskId);
+ const guard = gm?.get(field);
+ if (!gm || !guard) return;
+ guard.pendingWrites.delete(sequence);
+ releaseIfUnguarded(taskId, field, gm, guard);
+}
+
+/**
+ * AC-68: ends the open-editor contribution for both fields on unmount /
+ * route change. Deliberately leaves `pendingWriteCount` untouched — a write
+ * issued before unmount must still resolve and record (AC-69).
+ */
+export function endAllEditorsForTask(taskId: string): void {
+ const gm = guardByTaskField.get(taskId);
+ if (!gm) return;
+ for (const field of FIELDS) {
+ const guard = gm.get(field);
+ if (guard?.editorOpen) {
+ guard.editorOpen = false;
+ releaseIfUnguarded(taskId, field, gm, guard);
+ }
+ }
+}
+
+/** Test-only: clears all module state between test cases. */
+export function __resetOfficeTaskContentSyncForTests(): void {
+ canonicalByTaskField.clear();
+ guardByTaskField.clear();
+ sequenceByTask.clear();
+ lastWriteIssueSequenceByTaskField.clear();
+ lastSuccessfulWriteSequenceByTaskField.clear();
+ listenersByTaskField.clear();
+}
diff --git a/apps/web/lib/state/slices/office/office-refetch-trigger.test.ts b/apps/web/lib/state/slices/office/office-refetch-trigger.test.ts
new file mode 100644
index 0000000000..75f0a4a40f
--- /dev/null
+++ b/apps/web/lib/state/slices/office/office-refetch-trigger.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from "vitest";
+import { create } from "zustand";
+import { immer } from "zustand/middleware/immer";
+import { createOfficeSlice } from "./office-slice";
+import type { OfficeSlice } from "./types";
+
+function makeStore() {
+ return create()(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ immer((...a) => ({ ...(createOfficeSlice as any)(...a) })),
+ );
+}
+
+/**
+ * A single WS event routinely fires setOfficeRefetchTrigger more than once in
+ * the same synchronous handler (e.g. `task:` immediately followed by
+ * `dashboard` for office.task.updated). Both calls must survive together in
+ * one trigger object — otherwise React's automatic batching of the resulting
+ * store updates only ever lets a listener observe the last write, silently
+ * starving any listener keyed on the earlier type.
+ */
+describe("setOfficeRefetchTrigger — same-tick batching", () => {
+ it("carries every type fired synchronously in the same trigger", async () => {
+ const store = makeStore();
+
+ store.getState().setOfficeRefetchTrigger("task:t-1");
+ store.getState().setOfficeRefetchTrigger("dashboard");
+
+ // The batched flush happens in a microtask.
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(store.getState().office.refetchTrigger?.types).toEqual(["task:t-1", "dashboard"]);
+ });
+
+ it("de-dupes a type fired twice in the same tick", async () => {
+ const store = makeStore();
+
+ store.getState().setOfficeRefetchTrigger("dashboard");
+ store.getState().setOfficeRefetchTrigger("dashboard");
+
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(store.getState().office.refetchTrigger?.types).toEqual(["dashboard"]);
+ });
+
+ it("starts a fresh trigger object for calls in a later tick", async () => {
+ const store = makeStore();
+
+ store.getState().setOfficeRefetchTrigger("tasks");
+ await Promise.resolve();
+ await Promise.resolve();
+ const first = store.getState().office.refetchTrigger;
+
+ store.getState().setOfficeRefetchTrigger("dashboard");
+ await Promise.resolve();
+ await Promise.resolve();
+ const second = store.getState().office.refetchTrigger;
+
+ expect(first?.types).toEqual(["tasks"]);
+ expect(second?.types).toEqual(["dashboard"]);
+ expect(second).not.toBe(first);
+ });
+});
diff --git a/apps/web/lib/state/slices/office/office-slice.ts b/apps/web/lib/state/slices/office/office-slice.ts
index 9438a4489e..aa43c5c93d 100644
--- a/apps/web/lib/state/slices/office/office-slice.ts
+++ b/apps/web/lib/state/slices/office/office-slice.ts
@@ -42,7 +42,7 @@ export const defaultOfficeState: OfficeSliceState = {
},
meta: null,
isLoading: false,
- refetchTriggers: {},
+ refetchTrigger: null,
routing: {
byWorkspace: {},
knownProviders: [],
@@ -220,6 +220,11 @@ function createTaskActions(set: SetFn) {
}
function createMiscActions(set: SetFn) {
+ // Per-store closure state for batching setOfficeRefetchTrigger calls; see
+ // that action below.
+ let pendingRefetchTypes: string[] = [];
+ let refetchFlushScheduled = false;
+
return {
setApprovals: (approvals: OfficeSlice["office"]["approvals"]) =>
set((draft) => {
@@ -271,11 +276,28 @@ function createMiscActions(set: SetFn) {
set((draft) => {
draft.office.isLoading = loading;
}),
- setOfficeRefetchTrigger: (type: string) =>
- set((draft) => {
- const prev = draft.office.refetchTriggers[type] ?? 0;
- draft.office.refetchTriggers[type] = prev + 1;
- }),
+ // Same-tick calls (e.g. `task:` immediately followed by `dashboard`
+ // for one WS event) are buffered and flushed as a single trigger object
+ // in a microtask. Without this, React's automatic batching of the
+ // resulting store updates only ever lets a listener observe the *last*
+ // write in the tick — any earlier type in the same burst is silently
+ // dropped for a listener whose effect hasn't run in between the two
+ // `set()` calls.
+ setOfficeRefetchTrigger: (type: string) => {
+ if (!pendingRefetchTypes.includes(type)) {
+ pendingRefetchTypes.push(type);
+ }
+ if (refetchFlushScheduled) return;
+ refetchFlushScheduled = true;
+ queueMicrotask(() => {
+ const types = pendingRefetchTypes;
+ pendingRefetchTypes = [];
+ refetchFlushScheduled = false;
+ set((draft) => {
+ draft.office.refetchTrigger = { types, timestamp: Date.now() };
+ });
+ });
+ },
};
}
diff --git a/apps/web/lib/state/slices/office/types.ts b/apps/web/lib/state/slices/office/types.ts
index 55a2f527ac..bcc5f92b12 100644
--- a/apps/web/lib/state/slices/office/types.ts
+++ b/apps/web/lib/state/slices/office/types.ts
@@ -504,6 +504,18 @@ import type {
// --- Slice state & actions ---
+/**
+ * One or more refetch types fired together. A single WS event routinely
+ * triggers several types in the same synchronous handler (e.g. `task:`
+ * and `dashboard`); batching them into one trigger object is what lets
+ * `useOfficeRefetch` observe every type instead of only the last write to
+ * survive React's automatic batching of same-tick store updates.
+ */
+export type OfficeRefetchTrigger = {
+ types: string[];
+ timestamp: number;
+};
+
/**
* Office collections that belong to one workspace, stored per workspace id
* rather than as a single current value.
@@ -538,12 +550,12 @@ export type OfficeSliceState = {
tasks: TasksState;
meta: OfficeMeta | null;
isLoading: boolean;
- // Per-type counters rather than one "last trigger" value: a single WS
- // handler often fires several distinct types in the same synchronous
- // call (e.g. `task:${id}` then `dashboard`), and React/Zustand coalesce
- // those into one render, so a shared last-value field would only ever
- // let the final type's subscribers see a change. See useOfficeRefetch.
- refetchTriggers: Record;
+ // A single WS handler often fires several distinct types in the same
+ // synchronous call (e.g. `task:${id}` then `dashboard`); batching them
+ // into one OfficeRefetchTrigger object per tick is what lets every
+ // matching subscriber observe its type instead of only the last write
+ // surviving React's automatic batching. See useOfficeRefetch.
+ refetchTrigger: OfficeRefetchTrigger | null;
routing: RoutingState;
providerHealth: ProviderHealthSliceState;
runAttempts: RunAttemptsState;
diff --git a/apps/web/lib/ws/handlers/office.test.ts b/apps/web/lib/ws/handlers/office.test.ts
index 53b47fbe4b..90439eea87 100644
--- a/apps/web/lib/ws/handlers/office.test.ts
+++ b/apps/web/lib/ws/handlers/office.test.ts
@@ -1,7 +1,12 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { StoreApi } from "zustand";
import type { AppState } from "@/lib/state/store";
import { registerOfficeHandlers } from "./office";
+import {
+ __resetOfficeTaskContentSyncForTests,
+ beginWrite,
+ openFieldEditor,
+} from "@/lib/state/office-task-content-sync";
/**
* Minimal in-memory store for the office WS handler tests.
@@ -13,7 +18,15 @@ function makeStore(activeWorkspaceId: string | null) {
const upsertProviderHealth = vi.fn();
const appendRunAttempt = vi.fn();
const setWorkspaceRouting = vi.fn();
- const patchTaskInStore = vi.fn();
+ // Mirrors the real patchTaskInStore action's field-merge behavior (minus
+ // status normalization, which these handler tests don't exercise) so the
+ // content-editing guard tests can assert on actual post-patch item state,
+ // not just on how the mock was called.
+ const patchTaskInStore = vi.fn((taskId: string, patch: Record) => {
+ const items = state.office.tasks.items as Array<{ id: string } & Record>;
+ const idx = items.findIndex((t) => t.id === taskId);
+ if (idx >= 0) Object.assign(items[idx], patch);
+ });
let state = {
workspaces: { items: [], activeId: activeWorkspaceId },
office: { tasks: { items: [] } },
@@ -54,6 +67,7 @@ function makeStore(activeWorkspaceId: string | null) {
}
const ACTIVE_WS = "ws-current";
+const ACTION_TASK_UPDATED = "office.task.updated";
describe("office WS handler — workspace filter", () => {
beforeEach(() => {
@@ -142,11 +156,11 @@ describe("office WS handler — task field updates", () => {
it("maps the producer state field on office.task.updated", () => {
const { store, patchTaskInStore } = makeStore(ACTIVE_WS);
const handlers = registerOfficeHandlers(store);
- const handler = handlers["office.task.updated"]!;
+ const handler = handlers[ACTION_TASK_UPDATED]!;
handler({
type: "notification",
- action: "office.task.updated",
+ action: ACTION_TASK_UPDATED,
payload: { workspace_id: ACTIVE_WS, task_id: "t-1", state: "SCHEDULING" },
} as Parameters[0]);
@@ -226,6 +240,109 @@ describe("office WS handler — task field updates", () => {
});
});
+const REMOTE_TITLE = "Remote title";
+const REMOTE_DESCRIPTION = "Remote description";
+
+describe("office WS handler — content-editing guard (AC-61)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ __resetOfficeTaskContentSyncForTests();
+ });
+
+ function seedTaskItem(store: StoreApi) {
+ store.setState(
+ () =>
+ ({
+ office: {
+ tasks: {
+ items: [
+ { id: "t-1", title: "Old title", description: "Old description", status: "todo" },
+ ],
+ },
+ },
+ }) as unknown as Partial,
+ );
+ }
+
+ it("leaves a guarded field's store entry unchanged, including when the write originates in this handler", () => {
+ const { store } = makeStore(ACTIVE_WS);
+ seedTaskItem(store);
+ openFieldEditor("t-1", "title");
+ const handlers = registerOfficeHandlers(store);
+ const handler = handlers[ACTION_TASK_UPDATED]!;
+
+ handler({
+ type: "notification",
+ action: ACTION_TASK_UPDATED,
+ payload: {
+ workspace_id: ACTIVE_WS,
+ task_id: "t-1",
+ title: REMOTE_TITLE,
+ description: REMOTE_DESCRIPTION,
+ status: "in_progress",
+ },
+ } as Parameters[0]);
+
+ const item = store.getState().office.tasks.items[0] as {
+ title: string;
+ description: string;
+ status: string;
+ };
+ expect(item.title).toBe("Old title");
+ expect(item.description).toBe("Remote description");
+ expect(item.status).toBe("in_progress");
+ });
+
+ it("leaves both fields' store entries unchanged while a write is in flight for each", () => {
+ const { store } = makeStore(ACTIVE_WS);
+ seedTaskItem(store);
+ beginWrite("t-1", "title", 1);
+ beginWrite("t-1", "description", 1);
+ const handlers = registerOfficeHandlers(store);
+ const handler = handlers[ACTION_TASK_UPDATED]!;
+
+ handler({
+ type: "notification",
+ action: ACTION_TASK_UPDATED,
+ payload: {
+ workspace_id: ACTIVE_WS,
+ task_id: "t-1",
+ title: REMOTE_TITLE,
+ description: REMOTE_DESCRIPTION,
+ },
+ } as Parameters[0]);
+
+ const item = store.getState().office.tasks.items[0] as { title: string; description: string };
+ expect(item.title).toBe("Old title");
+ expect(item.description).toBe("Old description");
+ });
+
+ it("applies both fields normally when neither is guarded", () => {
+ const { store } = makeStore(ACTIVE_WS);
+ seedTaskItem(store);
+ const handlers = registerOfficeHandlers(store);
+ const handler = handlers[ACTION_TASK_UPDATED]!;
+
+ handler({
+ type: "notification",
+ action: ACTION_TASK_UPDATED,
+ payload: {
+ workspace_id: ACTIVE_WS,
+ task_id: "t-1",
+ title: REMOTE_TITLE,
+ description: REMOTE_DESCRIPTION,
+ },
+ } as Parameters[0]);
+
+ const item = store.getState().office.tasks.items[0] as { title: string; description: string };
+ expect(item.title).toBe("Remote title");
+ expect(item.description).toBe("Remote description");
+ });
+});
+
describe("office WS handler — approval flow events", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/apps/web/lib/ws/handlers/office.ts b/apps/web/lib/ws/handlers/office.ts
index f484f0ace7..533ae9d060 100644
--- a/apps/web/lib/ws/handlers/office.ts
+++ b/apps/web/lib/ws/handlers/office.ts
@@ -7,6 +7,7 @@ import type {
ProviderHealth,
RouteAttempt,
} from "@/lib/state/slices/office/types";
+import { isFieldGuarded } from "@/lib/state/office-task-content-sync";
/**
* Registers WS handlers for office domain events.
@@ -61,7 +62,7 @@ function buildTaskHandlers(
if (!isCurrentWorkspace(p)) return;
const taskId = (p.task_id ?? p.id) as string | undefined;
if (!taskId) return;
- updateTaskStatus(taskId, normalizeIssueFields(p));
+ updateTaskStatus(taskId, stripGuardedContentFields(taskId, normalizeIssueFields(p)));
// Per-task channel — the detail page subscribes to refresh the
// server-authoritative task DTO after a property mutation.
triggerRefetch(`task:${taskId}`);
@@ -298,3 +299,18 @@ function normalizeIssueFields(p: Record): Record,
+): Record {
+ const out = { ...fields };
+ if (isFieldGuarded(taskId, "title")) delete out.title;
+ if (isFieldGuarded(taskId, "description")) delete out.description;
+ return out;
+}
diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json
index e03fd6324b..5aee9aca1c 100644
--- a/apps/web/src/locales/en/task.json
+++ b/apps/web/src/locales/en/task.json
@@ -22,6 +22,7 @@
"addApprover": "+ Add approver",
"addAtLeastOneSource": "Add at least one source.",
"addBlocker": "+ Add blocker",
+ "addDescription": "Add a description...",
"addDetailsAboutThisChange": "Add details about this change...",
"addFolder": "Add folder",
"addFolderExecutorUnknown": "Waiting to confirm this task's executor supports folders",
@@ -359,6 +360,7 @@
"describeYourChanges": "Describe your changes...",
"description": "description",
"description2": "Description",
+ "descriptionRunningNote": "A session is running for this task. Saving now will not interrupt it.",
"destroyTerminalConfirm": "Destroy \"{{label}}\"? This kills the PTY and removes it.",
"detach": "Detach",
"detachFromParent": "Detach from parent",
@@ -379,6 +381,8 @@
"discardChanges2": "Discard changes",
"discardChangesToFile": "This will permanently discard all changes to <2>{{file}}2>. This action cannot be undone.",
"discardCount": "Discard {{selectionCount}}",
+ "discardDescriptionBody": "Your changes to the description have not been saved. Discard them?",
+ "discardDescriptionTitle": "Discard description changes?",
"discardFailed": "Discard failed",
"dismiss": "Dismiss",
"displayNameOptional": "Display name (optional)",
@@ -647,6 +651,7 @@
"kandevUpdateTask": "Kandev: Update Task",
"kandevUpdateTaskPlan": "Kandev: Update Task Plan",
"kandevWriteTaskDocument": "Kandev: Write Task Document",
+ "keepEditing": "Keep editing",
"keepOpen": "Keep Open",
"keepRunning": "Keep running",
"keyAndContentAreRequired": "Key and content are required",
@@ -1562,6 +1567,7 @@
"title2": "{{longName}} title",
"title3": "{{longName}} title...",
"titleOptional": "Title (optional)",
+ "titleRequired": "Title can't be empty.",
"toFocus": "to focus",
"todos": "Todos",
"todosCompleted": "{{completed}}/{{total}} completed",
diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json
index e2c00f5577..819ba94b20 100644
--- a/apps/web/src/locales/pseudo/task.json
+++ b/apps/web/src/locales/pseudo/task.json
@@ -22,6 +22,7 @@
"addApprover": "+ Àďď àƥƥŕōvēŕ",
"addAtLeastOneSource": "Àďď àţ ĺēàśţ ōńē śōũŕćē.",
"addBlocker": "+ Àďď ƀĺōćķēŕ",
+ "addDescription": "Àďď à ďēśćŕĩƥţĩōń...",
"addDetailsAboutThisChange": "Àďď ďēţàĩĺś àƀōũţ ţĥĩś ćĥàńĝē...",
"addFolder": "Àďď ƒōĺďēŕ",
"addFolderExecutorUnknown": "Ŵàĩţĩńĝ ţō ćōńƒĩŕḿ ţĥĩś ţàśķ'ś ēxēćũţōŕ śũƥƥōŕţś ƒōĺďēŕś",
@@ -359,6 +360,7 @@
"describeYourChanges": "Ďēśćŕĩƀē ŷōũŕ ćĥàńĝēś...",
"description": "ďēśćŕĩƥţĩōń",
"description2": "Ďēśćŕĩƥţĩōń",
+ "descriptionRunningNote": "À śēśśĩōń ĩś ŕũńńĩńĝ ƒōŕ ţĥĩś ţàśķ. Śàvĩńĝ ńōŵ ŵĩĺĺ ńōţ ĩńţēŕŕũƥţ ĩţ.",
"destroyTerminalConfirm": "Ďēśţŕōŷ \"{{label}}\"? Ţĥĩś ķĩĺĺś ţĥē ƤŢŶ àńď ŕēḿōvēś ĩţ.",
"detach": "Ďēţàćĥ",
"detachFromParent": "Ďēţàćĥ ƒŕōḿ ƥàŕēńţ",
@@ -379,6 +381,8 @@
"discardChanges2": "Ďĩśćàŕď ćĥàńĝēś",
"discardChangesToFile": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ďĩśćàŕď àĺĺ ćĥàńĝēś ţō <2>{{file}}2>. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
"discardCount": "Ďĩśćàŕď {{selectionCount}}",
+ "discardDescriptionBody": "Ŷōũŕ ćĥàńĝēś ţō ţĥē ďēśćŕĩƥţĩōń ĥàvē ńōţ ƀēēń śàvēď. Ďĩśćàŕď ţĥēḿ?",
+ "discardDescriptionTitle": "Ďĩśćàŕď ďēśćŕĩƥţĩōń ćĥàńĝēś?",
"discardFailed": "Ďĩśćàŕď ƒàĩĺēď",
"dismiss": "Ďĩśḿĩśś",
"displayNameOptional": "Ďĩśƥĺàŷ ńàḿē (ōƥţĩōńàĺ)",
@@ -647,6 +651,7 @@
"kandevUpdateTask": "Ķàńďēv: Ũƥďàţē Ţàśķ",
"kandevUpdateTaskPlan": "Ķàńďēv: Ũƥďàţē Ţàśķ Ƥĺàń",
"kandevWriteTaskDocument": "Ķàńďēv: Ŵŕĩţē Ţàśķ Ďōćũḿēńţ",
+ "keepEditing": "Ķēēƥ ēďĩţĩńĝ",
"keepOpen": "Ķēēƥ Ōƥēń",
"keepRunning": "Ķēēƥ ŕũńńĩńĝ",
"keyAndContentAreRequired": "Ķēŷ àńď ćōńţēńţ àŕē ŕēqũĩŕēď",
@@ -1562,6 +1567,7 @@
"title2": "{{longName}} ţĩţĺē",
"title3": "{{longName}} ţĩţĺē...",
"titleOptional": "Ţĩţĺē (ōƥţĩōńàĺ)",
+ "titleRequired": "Ţĩţĺē ćàń'ţ ƀē ēḿƥţŷ.",
"toFocus": "ţō ƒōćũś",
"todos": "Ţōďōś",
"todosCompleted": "{{completed}}/{{total}} ćōḿƥĺēţēď",
diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json
index af0c55f228..550a31a414 100644
--- a/apps/web/src/locales/pt-pt/task.json
+++ b/apps/web/src/locales/pt-pt/task.json
@@ -22,6 +22,7 @@
"addApprover": "+ Adicionar aprovador",
"addAtLeastOneSource": "Adicione pelo menos uma fonte.",
"addBlocker": "+ Adicionar bloqueio",
+ "addDescription": "Adicionar uma descrição...",
"addDetailsAboutThisChange": "Adicione detalhes sobre esta alteração...",
"addFolder": "Adicionar pasta",
"addFolderExecutorUnknown": "O executor desta tarefa ainda não foi confirmado",
@@ -344,6 +345,7 @@
"describeYourChanges": "Descreva as suas alterações...",
"description": "descrição",
"description2": "Descrição",
+ "descriptionRunningNote": "Há uma sessão em execução para esta tarefa. Guardar agora não a irá interromper.",
"destroyTerminalConfirm": "Destruir \"{{label}}\"? Isto termina o PTY e remove-o.",
"detach": "Separar",
"detachFromParent": "Separar da tarefa-mãe",
@@ -364,6 +366,8 @@
"discardChanges2": "Descartar as alterações",
"discardChangesToFile": "Isto descarta permanentemente todas as alterações a <2>{{file}}2>. Esta ação não pode ser anulada.",
"discardCount": "Descartar {{selectionCount}}",
+ "discardDescriptionBody": "As suas alterações à descrição não foram guardadas. Descartá-las?",
+ "discardDescriptionTitle": "Descartar as alterações à descrição?",
"discardFailed": "Não foi possível descartar",
"dismiss": "Dispensar",
"displayNameOptional": "Nome a apresentar (opcional)",
@@ -632,6 +636,7 @@
"kandevUpdateTask": "Kandev: Atualizar tarefa",
"kandevUpdateTaskPlan": "Kandev: Atualizar plano da tarefa",
"kandevWriteTaskDocument": "Kandev: Escrever documento da tarefa",
+ "keepEditing": "Continuar a editar",
"keepOpen": "Manter aberto",
"keepRunning": "Manter em execução",
"keyAndContentAreRequired": "A chave e o conteúdo são obrigatórios",
@@ -1517,6 +1522,7 @@
"title2": "Título do {{longName}}",
"title3": "Título do {{longName}}...",
"titleOptional": "Título (opcional)",
+ "titleRequired": "O título não pode estar vazio.",
"toFocus": "para focar",
"todos": "Tarefas por fazer",
"todosCompleted": "{{completed}}/{{total}} concluídas",
diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json
index 38c8cbd845..dff5e78c63 100644
--- a/apps/web/src/locales/zh-cn/task.json
+++ b/apps/web/src/locales/zh-cn/task.json
@@ -22,6 +22,7 @@
"addApprover": "+ 添加审批人",
"addAtLeastOneSource": "请至少添加一个来源。",
"addBlocker": "+ 添加阻塞项",
+ "addDescription": "添加描述…",
"addDetailsAboutThisChange": "添加此变更的详细说明…",
"addFolder": "添加文件夹",
"addFolderExecutorUnknown": "尚未确认此任务的执行器",
@@ -344,6 +345,7 @@
"describeYourChanges": "描述你的变更…",
"description": "描述",
"description2": "描述",
+ "descriptionRunningNote": "此任务有一个正在运行的会话。现在保存不会中断它。",
"destroyTerminalConfirm": "销毁“{{label}}”?这将终止 PTY 并移除它。",
"detach": "分离",
"detachFromParent": "从父任务分离",
@@ -364,6 +366,8 @@
"discardChanges2": "丢弃更改",
"discardChangesToFile": "将永久丢弃对 <2>{{file}}2> 的全部更改。此操作无法撤销。",
"discardCount": "丢弃 {{selectionCount}}",
+ "discardDescriptionBody": "对描述的更改尚未保存。是否丢弃?",
+ "discardDescriptionTitle": "丢弃描述更改?",
"discardFailed": "丢弃失败",
"dismiss": "关闭",
"displayNameOptional": "显示名称(可选)",
@@ -631,6 +635,7 @@
"kandevUpdateTask": "Kandev:更新任务",
"kandevUpdateTaskPlan": "Kandev:更新任务计划",
"kandevWriteTaskDocument": "Kandev:写入任务文档",
+ "keepEditing": "继续编辑",
"keepOpen": "保持打开",
"keepRunning": "继续运行",
"keyAndContentAreRequired": "键与内容为必填",
@@ -1516,6 +1521,7 @@
"title2": "{{longName}} 标题",
"title3": "{{longName}} 标题…",
"titleOptional": "标题(可选)",
+ "titleRequired": "标题不能为空。",
"toFocus": "以聚焦",
"todos": "待办事项",
"todosCompleted": "已完成 {{completed}}/{{total}}",
diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json
index 4fac43bd74..3d1fa6f5ff 100644
--- a/apps/web/src/locales/zh-hk/task.json
+++ b/apps/web/src/locales/zh-hk/task.json
@@ -22,6 +22,7 @@
"addApprover": "+ 新增審批人",
"addAtLeastOneSource": "請至少新增一個來源。",
"addBlocker": "+ 新增阻塞項",
+ "addDescription": "新增描述…",
"addDetailsAboutThisChange": "新增此變更的詳細說明…",
"addFolder": "新增資料夾",
"addFolderExecutorUnknown": "尚未確認此任務的執行器",
@@ -344,6 +345,7 @@
"describeYourChanges": "描述你的變更…",
"description": "描述",
"description2": "描述",
+ "descriptionRunningNote": "此任務有一個正在執行的工作階段。現在儲存不會中斷它。",
"destroyTerminalConfirm": "銷燬「{{label}}」?這將終止 PTY 並移除它。",
"detach": "分離",
"detachFromParent": "從父任務分離",
@@ -364,6 +366,8 @@
"discardChanges2": "丟棄更改",
"discardChangesToFile": "將永久丟棄對 <2>{{file}}2> 的全部更改。此操作無法撤銷。",
"discardCount": "丟棄 {{selectionCount}}",
+ "discardDescriptionBody": "對描述的更改尚未儲存。是否丟棄?",
+ "discardDescriptionTitle": "丟棄描述更改?",
"discardFailed": "丟棄失敗",
"dismiss": "關閉",
"displayNameOptional": "顯示名稱(可選)",
@@ -631,6 +635,7 @@
"kandevUpdateTask": "Kandev:更新任務",
"kandevUpdateTaskPlan": "Kandev:更新任務計劃",
"kandevWriteTaskDocument": "Kandev:寫入任務文檔",
+ "keepEditing": "繼續編輯",
"keepOpen": "保持開啟",
"keepRunning": "繼續執行",
"keyAndContentAreRequired": "鍵與內容為必填",
@@ -1516,6 +1521,7 @@
"title2": "{{longName}} 標題",
"title3": "{{longName}} 標題…",
"titleOptional": "標題(可選)",
+ "titleRequired": "標題不能為空。",
"toFocus": "以聚焦",
"todos": "待辦事項",
"todosCompleted": "已完成 {{completed}}/{{total}}",
diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json
index f458f9a3a4..11a7eaa135 100644
--- a/apps/web/src/locales/zh-tw/task.json
+++ b/apps/web/src/locales/zh-tw/task.json
@@ -22,6 +22,7 @@
"addApprover": "+ 新增審批人",
"addAtLeastOneSource": "請至少新增一個來源。",
"addBlocker": "+ 新增阻塞項",
+ "addDescription": "新增描述…",
"addDetailsAboutThisChange": "新增此變更的詳細說明…",
"addFolder": "新增資料夾",
"addFolderExecutorUnknown": "尚未確認此任務的執行器",
@@ -344,6 +345,7 @@
"describeYourChanges": "描述你的變更…",
"description": "描述",
"description2": "描述",
+ "descriptionRunningNote": "此任務有一個正在執行的工作階段。現在儲存不會中斷它。",
"destroyTerminalConfirm": "銷燬「{{label}}」?這將終止 PTY 並移除它。",
"detach": "分離",
"detachFromParent": "從父任務分離",
@@ -364,6 +366,8 @@
"discardChanges2": "丟棄更改",
"discardChangesToFile": "將永久丟棄對 <2>{{file}}2> 的全部更改。此操作無法撤銷。",
"discardCount": "丟棄 {{selectionCount}}",
+ "discardDescriptionBody": "對描述的更改尚未儲存。是否丟棄?",
+ "discardDescriptionTitle": "丟棄描述更改?",
"discardFailed": "丟棄失敗",
"dismiss": "關閉",
"displayNameOptional": "顯示名稱(可選)",
@@ -631,6 +635,7 @@
"kandevUpdateTask": "Kandev:更新任務",
"kandevUpdateTaskPlan": "Kandev:更新任務計劃",
"kandevWriteTaskDocument": "Kandev:寫入任務檔案",
+ "keepEditing": "繼續編輯",
"keepOpen": "保持開啟",
"keepRunning": "繼續執行",
"keyAndContentAreRequired": "鍵與內容為必填",
@@ -1516,6 +1521,7 @@
"title2": "{{longName}} 標題",
"title3": "{{longName}} 標題…",
"titleOptional": "標題(可選)",
+ "titleRequired": "標題不能為空。",
"toFocus": "以聚焦",
"todos": "待辦事項",
"todosCompleted": "已完成 {{completed}}/{{total}}",
diff --git a/docs/specs/office-task-content-editing/spec.md b/docs/specs/office-task-content-editing/spec.md
new file mode 100644
index 0000000000..0b49120c58
--- /dev/null
+++ b/docs/specs/office-task-content-editing/spec.md
@@ -0,0 +1,1037 @@
+---
+status: specified
+created: 2026-08-19
+owner: kandev
+---
+
+# Office Task Content Editing
+
+## Why
+
+The Office task detail page (`/office/tasks/[id]`, simple pane) renders the task **title** and
+**description** as static text. Every other property on the page (status, priority, assignee,
+project, parent, blockers, reviewers, approvers, labels) is editable inline. The two fields that
+carry the most meaning are the two a user cannot change.
+
+The description is not decoration: it is the brief the assigned agent works from. A typo in the
+title, a missing constraint in the brief, or a scope note that arrived after the task was filed
+currently has exactly one remedy: leave the Office UI, open `/t/`, and edit from the Kanban
+surface. Users read the absence as a bug. It is not: `docs/specs/office/tasks.md:58` enumerates the
+editable properties and omits both fields, and the shipped code implements that list faithfully.
+This is a spec gap, and this document closes it.
+
+Nothing in the backend blocks it. `PATCH /api/v1/tasks/:id` has accepted `title` and `description`
+since before Office existed, both surfaces read the same `tasks` row, and the resulting
+`task.updated` event already drives the Office detail page's live refresh.
+
+## What
+
+### A. Scope of the affordance
+
+- The title and description of a task are editable **in place** on the Office task detail page's
+ simple pane. No dialog, no navigation, no separate edit mode for the page as a whole.
+- The affordance is available on every task the detail page can render, in every task status
+ (including `done` and `cancelled`) and in every session state (including `RUNNING`). No new
+ permission, ownership, or lifecycle gate is introduced. This matches the sidebar property
+ pickers, which are likewise ungated.
+- **Archived tasks are not a special case here.** The Kanban title editor
+ (`apps/web/components/task/task-top-bar-title.tsx`) gates renaming on an `isArchived` prop, and
+ parts of that component are reused for the title editor. **That gate is deliberately NOT ported.**
+ The Office detail page does not render an archived state, does not receive an equivalent flag, and
+ `Service.UpdateTask` enforces no archived precondition of its own, so porting the gate would
+ introduce a condition that can never be true and would read as dead code. AC-30 and AC-47 govern.
+- Only the simple pane is in scope. The advanced / dockview mode is unchanged.
+
+### B. Title editing (inline, Enter to commit)
+
+- The title renders as a text element that the user activates by **double-click**, or by focusing
+ it and pressing **Enter**. Activation replaces it with a single-line text input seeded with the
+ current title, focused, with its contents selected.
+- **Enter** commits. **Escape** cancels and discards the draft. **Blur** cancels and discards the
+ draft.
+- **The title baseline.** When the title editor opens it captures the **displayed** title at that
+ moment as its **frozen baseline**, frozen for the lifetime of the editor. Wherever this document
+ says "the stored title" while a title editor is open (AC-4's restore target, AC-8's equality
+ test) it means that frozen baseline, never a canonical value that arrived afterwards. This is
+ the same construct section C defines for the description and it exists for the same reason:
+ while the editor is open the displayed title is guarded (section E), so a remote edit must not
+ silently change what Escape restores or what counts as "unchanged". The canonical value that
+ arrived meanwhile is not lost. It is applied when the editor closes, under the deferred-apply
+ rule in section E. AC-51 governs.
+- Commit is **optimistic**: the new title renders immediately, the request is issued, and on
+ failure the previous title is restored and the error surfaces as a toast. Which value "the
+ previous title" means when commits overlap is defined in section F under "Rollback ordering".
+- The value that is **sent** and the value that is **optimistically rendered** are both the
+ whitespace-trimmed draft, so the visible title always equals the value the server will persist.
+ The server trims too; the client trims first so the two never disagree, even briefly.
+- The input is hard-clamped to **60 characters** as the user types, counted the same way the
+ backend counts (Unicode code points, `TaskTitleMaxLength = 60` in
+ `apps/backend/internal/task/service/task_title.go`). The caret must not jump to the end of the
+ field when a keystroke is clamped away.
+- **Paste is clamped identically to typing**: the pasted text is inserted at the caret, the result
+ is clamped to the first 60 code points, and the caret stays at the end of the inserted run (or at
+ the clamp boundary when the insertion is cut short). Paste is not a separate code path with
+ separate truncation rules.
+- A commit whose trimmed draft is **empty** is refused client-side: the editor stays open, the
+ field is marked invalid, and no request is issued. The draft is never silently discarded.
+- A commit whose trimmed draft **equals the current title** issues no request and closes the editor.
+- IME composition must not commit. An `Enter` that the IME is using to accept a candidate is
+ consumed by the IME, not by the editor.
+
+### C. Description editing (explicit Save and Cancel)
+
+- The description renders as static text that the user activates by **clicking** it. Activation
+ replaces it with a multi-line textarea seeded with the current description, focused, caret at the
+ end.
+- When the task has **no description**, the page renders a persistent placeholder affordance in the
+ description's position that activates the same editor. An empty description must never leave the
+ user with nothing to click.
+- **"No description" is defined as: the stored description, after trimming, is the empty string.**
+ This single definition covers `null`, `""`, and a field omitted from the DTO (the Office task DTO
+ serialises `description` with `omitempty`, so all three reach the client as the same absence).
+ There is no fourth state and no visible difference between them.
+- **The editor baseline.** When the editor opens it captures the current stored description as its
+ **baseline**, and that baseline is **frozen for the lifetime of the editor**. Every
+ draft-versus-stored comparison in this document (Save enabled/disabled, Cancel confirm/no-confirm,
+ Escape inert/closing) compares the trimmed draft against the **frozen baseline**, never against a
+ value that may have changed underneath. A successful save replaces the baseline with the value
+ that was saved. Nothing else replaces it. The rationale is in section F under "Late-arriving
+ refresh": without a frozen baseline, a remote edit by another client silently flips Save,
+ Cancel, and Escape behaviour while the user is typing, with no action of their own.
+- Comparisons use the **trimmed** draft, and the value sent is the trimmed draft. A whitespace-only
+ draft is therefore equal to an empty one, so a whitespace-only edit on an empty description leaves
+ Save disabled rather than issuing a save that the server would silently reduce to a no-op.
+- The editor commits only on an explicit **Save**. **Cancel** discards. **Blur does not commit and
+ does not close the editor**: the draft survives clicking elsewhere on the page.
+- **Escape** discards the draft and closes the editor when the draft is unchanged from the baseline.
+ When the draft *is* changed, Escape does nothing; the user must choose Save or Cancel.
+- **Cancel with a changed draft** requires a confirmation before the draft is discarded. Cancel with
+ an unchanged draft closes immediately with no prompt.
+- Save is disabled while the trimmed draft equals the baseline, and while a save is in flight.
+- Save is **not** optimistic: the editor stays open and shows a saving state until the request
+ resolves. The non-optimism is end to end, not just on this page: the description's Office task
+ store patch, which drives card previews in list and board views, is also applied only on success
+ (section E, AC-56). A card preview that updated before the save resolved would contradict this
+ bullet on a different surface. On failure the editor stays open with the draft intact and the
+ error surfaces as a toast, so a rejected save never destroys typing.
+- **While a save is in flight** the textarea **stays editable**; Save and Cancel are both disabled
+ and Escape does nothing. Disabling the textarea instead would silently swallow keystrokes the user
+ has already begun typing.
+- **On a successful save**, if the current trimmed draft still equals the text that was submitted,
+ the editor closes and the saved value renders. If the user **edited the draft while the save was
+ in flight**, the editor **stays open with the newer draft intact** and the baseline becomes the
+ value that was just saved, so Save re-enables for the newer text. A successful save must never
+ discard typing that the user did after pressing it. This is the same principle as the failure
+ path, applied to the success path.
+- The description is **plain text**, rendered with preserved whitespace. This feature introduces no
+ Markdown rendering and no rich-text editing.
+- There is no length limit on the description, client-side or server-side.
+
+**Why the two fields differ.** The title is one line, bounded at 60 characters, and cheap to retype
+if a commit is lost; optimistic-with-discard matches the pickers and the existing Kanban rename. The
+description is unbounded prose that is also the agent's brief: Kandev stores no description version
+history, so a save that fires by accident, or a draft dropped on blur, is unrecoverable. The
+industry split follows exactly that line. Linear and Notion autosave and have undo; Jira and GitHub
+use explicit buttons and do not. Kandev is in the second group. `project-header.tsx` already gates a
+Save affordance on a dirty long-form Office textarea, which is the precedent for requiring an
+explicit commit; it is **not** a precedent for this feature's control layout, which section H
+specifies directly.
+
+### D. What an edit does NOT do
+
+- A title or description edit **does not wake, interrupt, or re-prompt the assigned agent**, and
+ does not queue a run. Verified: `Service.UpdateTask` publishes `task.updated`
+ (`service_tasks.go:1607`); the only Office subscriber for that event is `handleTaskUpdated`
+ (`internal/office/service/event_subscribers.go:749`), which reads
+ `assignee_agent_profile_id` off the payload; `publishTaskEventNow`
+ (`service_events.go:370-436`) never puts that field on a `task.updated` payload. With an empty
+ profile id and `fallbackToStoredRunner = false`, `queueTaskAssignedRun` returns without queuing.
+ Note that `TaskUpdatedData` **declares** that field even though this publisher never sets it, so
+ the invariant is one added line away from breaking. AC-31 is therefore a tested contract, not a
+ standing code-reading.
+- Consequently, a running agent continues on the brief it was launched with. An edited description
+ is picked up at the agent's **next wakeup**, produced by the existing triggers (a comment, an
+ assignment change, a status change), not by the edit.
+- Because this surprises users, the description editor shows a **non-blocking inline note** while at
+ least one session on the task is in state `RUNNING`, stating that the change applies at the
+ agent's next wakeup. The note never blocks Save.
+- An edit does not change task status, does not move the task between workflow steps, and records no
+ approval decision.
+
+### E. Persistence and live update
+
+- Both fields are written through the existing `PATCH /api/v1/tasks/:id` endpoint (frontend:
+ `updateTask()` in `apps/web/lib/api/domains/kanban-api.ts`). The Office endpoint
+ `PATCH /api/v1/office/tasks/:id` is **not** extended, and its documented field list in
+ `docs/specs/office/tasks.md:229` remains correct.
+- The request carries **only the field being edited**. A title commit sends `{ title }`; a
+ description save sends `{ description }`.
+- The server **trims leading and trailing whitespace** from both fields before persisting
+ (`task_http_handlers.go`, `httpUpdateTask`). The client trims before sending as well (sections B
+ and C), so the rendered value equals the stored value at every moment, not merely after a
+ round-trip.
+- After a successful write the backend publishes `task.updated`, which the Office WS broadcaster
+ re-emits as `office.task.updated`, which triggers `triggerRefetch("task:")` and re-fetches the
+ canonical task DTO. Other open clients converge without polling. No new event, subject, or
+ subscriber is introduced.
+- **Canonical value versus displayed value.** These are two different things and the distinction
+ carries the whole of this section. The **canonical value** of a field is the value this client
+ last learned the server holds. The **displayed value** is what renders on the page, what seeds an
+ editor, and what the Office task store entry carries for list and board cards. They are usually
+ equal. They diverge exactly while a local edit is in flight or an editor is open, and the rules
+ below say how they re-converge. Nothing in this feature ever discards a canonical value in order
+ to protect a draft; it defers applying it.
+- **What records a canonical value: the complete list.** Exactly three events record a canonical
+ value for a field, and nothing else does.
+ 1. **The initial task load** for the page records both fields (AC-59).
+ 2. **A refetch resolving**, unless the response is discarded by one of the two ordering rules
+ below (AC-49).
+ 3. **A successful write resolving**, which records the value that request persisted for the field
+ it wrote, read from the response body. `updateTask()` already returns the updated task
+ (`apps/web/lib/api/domains/kanban-api.ts`), so this costs no extra request. The recording
+ happens **before** the write releases that field's guard (AC-58).
+ **Every recording of `description` is normalised to the empty string first.** `undefined`, `null`,
+ and a field omitted from the payload all record as `""`. This is not a tidiness rule and it is not
+ optional: the two sources genuinely disagree in their types. The Office task DTO declares
+ `description` **optional** (`OfficeTask` in `apps/web/lib/state/slices/office/types.ts`, and
+ the Go side serialises it with `omitempty`), so a load or refetch recording can carry
+ `undefined`; a write response carries `Task.description`, which is a **required** string
+ (`apps/web/lib/types/http.ts`), so it records `""`. Without normalisation those two are unequal,
+ AC-50 applies a canonical value "if the two differ", and every guard release on a task with no
+ description would fire a spurious apply forever. Normalising at the recording site makes the
+ canonical value, the displayed value and the store entry use the one definition section C already
+ gives for "no description", so all three agree. **`title` needs no equivalent rule** and must not
+ be given one by symmetry: both DTOs declare it a required string. AC-71 governs.
+ Recording is otherwise **unconditional**: it is never suppressed by an open editor or an in-flight
+ write. Only the **application** of a canonical value to the displayed value is guarded (AC-49).
+ The third trigger is not bookkeeping. Without it the client's own successful edit is never
+ recorded, so the deferred apply defined below would land the pre-write value the instant the guard
+ released and visibly revert a save that had just succeeded.
+- **Stale responses are discarded, not recorded.** Each fetch records, **per field**, the client's
+ write generation for that field at the moment the request is issued. A response is **stale for a
+ field** if this client committed a write to that field after the request was issued, and a stale
+ response is discarded **for that field only**: it is neither recorded as canonical nor applied.
+ Other fields of the same response are unaffected. This is what stops a refetch begun before a
+ save from resolving afterwards and reinstating the pre-save text (AC-52). Staleness is judged by
+ request-issue order, not by comparing values, so a response that happens to carry a third party's
+ newer value but was requested before this client's write is discarded too. That is deliberate and
+ costs nothing durable: the third party's write published its own `task.updated`, so a fresh
+ refetch is already on its way.
+- **Recordings are ordered by `tasks.updated_at`, and a strictly older recording is discarded.**
+ A candidate recording is accepted only if the `updated_at` its payload carries for the task is
+ **not older** than the `updated_at` carried by the field's currently recorded canonical value. A
+ strictly older candidate is discarded for that field: neither recorded nor applied (AC-60).
+ `updated_at` is the right column precisely because section F already establishes that it is
+ stamped **inside** the write transaction, so its order matches true serialization order.
+ - **This applies to all three record triggers**, a write's own response (AC-58) included, not only
+ to refetches. That is load-bearing rather than tidiness: when two title commits overlap and the
+ server commits them in the opposite order to the one the client issued them in, the two write
+ responses carry the row's two successive `updated_at` values, so ordering by that column records
+ whichever write the row actually kept. Recording by arrival order instead would leave the
+ canonical value at the losing write. This is a second, independent reason AC-54 converges.
+ - **Tiebreak, named.** When two instants are exactly equal, the later-arriving recording wins,
+ ordered by this client's request-issue sequence for the task (AC-63). That sequence is a single
+ per-task counter, incremented on **every** request this client issues for the task, fetch or
+ write, so every candidate recording carries one. Equal `updated_at` means
+ the same row version, so the field values agree and the choice is not observable; the tiebreak
+ exists so the rule is total instead of leaving Build to invent one, and it does real work only
+ where a second-resolution timestamp format is in play.
+ - **Compare parsed instants, never raw strings, and note that the two formats are further apart
+ than they look.** The three record triggers do not agree on serialization, and the disagreement
+ is not a fractional-second detail. It is two different date syntaxes.
+ - **The Office task DTO** feeds two of the three triggers, the initial load (AC-59) and every
+ refetch (AC-49). It passes the SQLite `updated_at` column straight through as a raw string
+ (`internal/office/repository/sqlite/tasks.go`, `TaskSearchResult.UpdatedAt string`). What is
+ in that column is written by `updateTaskTx`, which binds `task.UpdatedAt` as a Go `time.Time`
+ to `mattn/go-sqlite3`; that driver's default bind layout is
+ `2006-01-02 15:04:05.999999999-07:00`: **space-separated, with a numeric offset (`+00:00`),
+ never a `T`, never a `Z`, and not ISO-8601**. This is established in this repository, not
+ inferred: `internal/analytics/repository/sqlite/stats.go` carries a comment recording the bug
+ it already caused ("RFC3339 put a 'T' where every stored value has a space, and 'T' sorts
+ above ' ', so rows silently lost their first day"); the same layout is referenced in
+ `internal/office/repository/sqlite/failure.go` and
+ `internal/task/repository/sqlite/worktree_ownership_normalize.go`; and
+ `internal/office/dashboard/service_inbox.go`'s `parsedTaskUpdatedAt` needs **four** fallback
+ layouts and still falls back to `time.Now()` when all four miss.
+ - **A write's own response** (AC-58) carries `TaskDTO.UpdatedAt time.Time`
+ (`internal/task/dto/dto.go`), marshalled by `encoding/json`, which is RFC3339Nano: the
+ `T`/`Z` form. The `task.updated` WS payload uses the same form
+ (`internal/task/service/service_events.go`), though that payload is **not** a record trigger;
+ it is named here only so a builder does not add it as a fourth one.
+ So a comparison between a refetch recording and a write recording is a comparison between
+ `2026-08-20 14:30:05.123456789+00:00` and `2026-08-20T14:30:05.123456789Z`. As text they do not
+ order at all; the space sorts below `T`, so the raw-string comparison is not merely
+ imprecise, it is systematically wrong in one direction. The trailing-zero hazard is real too,
+ and survives within the RFC3339Nano form alone, because RFC3339Nano strips trailing zeros
+ from the fractional second, so `...:05.1Z` and `...:05.05Z` do not order correctly as text
+ either.
+ **The requirement:** parse every candidate's `updated_at` to an absolute instant before
+ comparing, accepting **both** layouts above. The space-separated form must be parsed
+ **explicitly**, treating its trailing offset as authoritative; it must not be handed to
+ `Date.parse` / `new Date(...)` and assumed, because that string is not an ISO-8601 date-time and
+ browsers do not agree on how or whether to parse it, with some treating it as local time. AC-66
+ governs.
+ - **A candidate whose `updated_at` cannot be parsed is discarded, and is never treated as
+ newest.** If parsing fails, that candidate is dropped for that field: neither
+ recorded as canonical nor applied to the displayed value. It must **not** fall back to the
+ current time, and must not be accepted on the grounds that it arrived most recently. The backend
+ precedent in `parsedTaskUpdatedAt` does fall back to `time.Now()`, and copying it here would be
+ actively wrong rather than merely lax: this rule discards anything strictly **older** than the
+ recorded value, so a "now" fallback always compares as newest, always wins, and silently defeats
+ the entire ordering rule for exactly the malformed inputs it exists to protect against. Dropping
+ the candidate is safe because no canonical value is lost: the field keeps the value it
+ already had, and the next load, refetch or successful write records again. AC-67 governs.
+ - **The first recording for a field is a seed and is never discarded.** The two rules above are
+ comparisons, and until a field has a recorded canonical value there is nothing to compare
+ against, so the first recording is made unconditionally: neither the staleness rule, nor the
+ `updated_at` ordering rule, nor the parse rule can reject it (AC-72). This matters because
+ section F's rollback target is the recorded canonical value (AC-64), so a field that reached an
+ editable state with no canonical value at all would leave a failed commit with nothing to
+ restore. AC-59 makes the initial page load the seed, and it necessarily precedes any edit,
+ so in practice the seed is always in place before a commit can be issued.
+ - **An unknown instant loses to a known one.** If the seed's `updated_at` could not be parsed,
+ the field has a canonical value but no comparable instant. Any later candidate whose
+ `updated_at` does parse supersedes it (AC-73), rather than being discarded for failing a
+ comparison against something uncomparable. Without this the field would be pinned to the seed
+ for the lifetime of the page.
+ This rule closes two races the write-generation rule above does not reach: two refetches resolving
+ out of order (AC-60), and a refetch issued *after* a commit that nonetheless read a pre-write
+ snapshot and resolves after the write's own response (AC-62).
+ **Which rule applies to which trigger, stated rather than left to be inferred.** A **refetch**
+ response must survive **both** the staleness rule and this ordering rule to be recorded. A
+ **write's own** response is subject to this ordering rule **only**; the staleness rule above is
+ written against a refetch response and must not be extended to write responses. Extending it
+ would be actively wrong rather than merely redundant: when two title commits overlap, the second
+ is by definition committed after the first was issued, so a staleness test would discard the first
+ commit's own response, and if the server happened to commit that first write last, the client
+ would record the losing value and disagree with the row permanently.
+- **The in-flight guard (applies to the displayed value only).** A canonical value is applied to a
+ field's displayed value **only when that field has no unresolved write of this client's own and
+ no open editor for it.** The guard is per field and covers three windows that would otherwise
+ each lose a user's work:
+ 1. an **open editor** (AC-28): the draft is never touched;
+ 2. an **in-flight optimistic title**, where the editor has already closed at commit time (AC-3),
+ so an editor-open test alone does not protect it (AC-38);
+ 3. a refetch triggered by an **unrelated concurrent mutation on the same task**, for example
+ another property picker firing while a title PATCH is still in flight; that refetch can read a
+ snapshot taken before this client's write and would otherwise revert it (AC-39).
+ A field with no unresolved write and no open editor is unguarded, and a canonical value lands on
+ it immediately, so an ordinary remote edit still appears promptly.
+- **Deferred apply on release.** A field is **unguarded** when both conditions hold at once: it has
+ no open editor and no unresolved write of this client's own. At the moment a field becomes
+ unguarded, whether because its editor closed by any route (Save, Cancel, Escape, discard-confirm)
+ or because its last in-flight write resolved, the client **applies the field's most recently
+ recorded canonical value** to the displayed value if the two differ. If several canonical values
+ were recorded while the field was guarded, only the most recent one is applied; the earlier ones
+ are superseded, not queued. On the successful-save path this runs **after** the editor closes and
+ AC-17 renders the saved value, and it can only ever move the display forward onto something newer,
+ never back onto the pre-save text. Three rules together are what make that true, and all three are
+ load-bearing: the save's own success has already recorded the persisted value as this field's
+ canonical value (AC-58), so the canonical value is not stale to begin with; a response whose
+ request predates the save is discarded as stale (AC-52); and any surviving response carrying an
+ older `updated_at` is discarded by the ordering rule (AC-60). Earlier drafts of this document
+ rested the same claim on AC-52 alone, which was wrong: a canonical value recorded legitimately
+ **before** the save was issued is not stale under AC-52 and would have been applied over the saved
+ text.
+ This is the half of the convention the guard would otherwise drop. Without it, a canonical value
+ that arrived while the field was guarded would be recorded and never shown: a remote edit landing
+ during an open editor that the user then cancels would leave stale text on the page forever,
+ because no further event is coming. AC-50 governs, and it is what keeps AC-27
+ true in the guarded case. `apps/web/AGENTS.md` names this exact shape as the established
+ convention for this class of race: "guard responses with per-scope revision and request/workspace
+ generation; discard or refresh stale responses **and cover deferred responses**".
+- **Guard lifetime: the guard is DERIVED, never independently set.** A field is guarded exactly when
+ at least one of two underlying facts holds: an editor is open for it, or a write of this
+ client's own to it is unresolved. The guard is computed from those two facts; it is **not** a
+ third piece of state that one code path sets and another must clear. That shape is
+ mandatory rather than stylistic, because a separately-mutated flag is precisely what can be left
+ set by a path that never runs its clear, and a permanently-set guard is invisible: it produces no
+ error, it silently freezes the field's Office task store entry for that task, and AC-61 makes the
+ WebSocket handler honour it, so the task's cards on the list and board stop updating for the rest
+ of the session with nothing to indicate why. The lifetime rules follow from the derivation and are
+ stated so Build does not have to derive them:
+ - **Creation.** No explicit creation step exists. A (task, field) pair is guarded from the moment
+ one of the two facts becomes true. The guard is keyed by task and field, not by component, and
+ there is at most one open-editor contribution per field because the Office simple pane is the
+ only surface this feature gives an editor to. A second view of the same task does not add a
+ second contribution.
+ - **Editor closing, including unmount and route change.** When the detail page unmounts, or the
+ route changes away from it, its open-editor contribution for both fields ends unconditionally,
+ exactly as if the editor had been closed (AC-68). An unmount is not a special case that skips
+ cleanup; it is the ordinary close path.
+ - **A write resolving.** A write's contribution ends when it resolves, **whether it succeeded or
+ failed** (AC-70). A failed write is a resolved write. This is called out because the failure
+ path is the one a builder is most likely to leave without a release, and because it also
+ records no canonical value, so nothing later would correct a guard left set by it.
+ - **A write that resolves after the page is gone.** It still records its canonical value (AC-58
+ is unconditional and does not depend on a mounted page) and it still ends its own
+ contribution. The deferred apply then has no displayed value to write to, so it applies to the
+ Office task store entry only (AC-69). The task's card therefore ends on the persisted value
+ even though the user navigated away mid-write.
+ - **Garbage collection.** No guard entry may outlive both of its underlying facts. Once a field
+ has no open editor and no unresolved write it is unguarded and holds no state, and an
+ implementation
+ that keeps a per-task map must not leave a set entry behind for a task the user has left
+ (AC-69).
+- **Why the guard is needed at all here.** `refetchTask` in
+ `apps/web/app/office/tasks/[id]/page.tsx` calls `setTask(mapOfficeTaskToTask(res.task))`,
+ replacing the whole task object rather than
+ merging fields, and `patchTaskInStore` writes the store entry the same way. Applying a response
+ wholesale is therefore the default behaviour that has to be constrained.
+- **The Office task store follows the same rule, with per-field timing.** A title edited on the
+ detail page must update the task's card in list and board views. The **title** store patch is
+ applied at **commit-issue** time, in lockstep with the optimistic render (AC-3), which is what
+ makes AC-39's protection meaningful: the store already carries the unconfirmed value while the
+ request is in flight. The **description** store patch is applied **only on success**, because
+ section C makes description Save non-optimistic and an optimistic card preview would contradict
+ it (AC-11, AC-56). Both require `title` and `description` to be carried by the store patch
+ mapping in `apps/web/hooks/use-optimistic-task-mutation.ts`, which currently drops both.
+- **There are TWO writers of these fields into the Office task store, and both consult the guard.**
+ The guard is a property of the task and the field, not of the detail-page component. Naming only
+ the first writer is what previously made AC-39 and AC-56 unsatisfiable:
+ 1. `patchTaskInStore`, reached from the optimistic-mutation hook and from the page's refetch path.
+ 2. **`apps/web/lib/ws/handlers/office.ts`.** Its `office.task.updated` handler calls
+ `updateTaskStatus(taskId, normalizeIssueFields(p))`. `normalizeIssueFields` maps `p.title` and
+ `p.description` off the event payload, and `updateTaskStatus` merges them into
+ `state.office.tasks.items` with a raw `store.setState`, **synchronously on every event**,
+ bypassing `patchTaskInStore` and everything this section constrains.
+ For `title` and `description` both writers are subject to the per-field guard (AC-61). The
+ practical consequence, which Build must plan for rather than discover: the guard state has to be
+ reachable from a module-level store subscriber, so component-local state alone cannot satisfy
+ AC-61. **Where** that state lives is Build's choice; that both writers read the same guard is not.
+- **Why the WS handler is routed through the guard rather than stripped.** Two alternatives were
+ considered and rejected, and this is recorded because the choice was left open rather than
+ directed. **Stripping** `title` and `description` from `normalizeIssueFields`, so the guarded
+ refetch path became the sole store writer, is the smaller diff and would also satisfy AC-39 and
+ AC-56. It was rejected because that handler is shared by the Office list and board, where no
+ editor is ever open and no write is ever in flight: stripping the two fields would make every
+ remote title change on those surfaces wait for a refetch instead of landing synchronously, which
+ is a regression for users on surfaces this feature otherwise does not touch. **Accepting** the
+ handler as an unguarded writer, and weakening AC-39 and AC-56 to match, was rejected because
+ AC-39 exists precisely for the routine case of an unrelated picker firing mid-write. Routing keeps
+ the synchronous fast path for the common unguarded case, keeps both ACs intact, and puts the rule
+ in one place, so a third writer added later inherits it instead of silently defeating it the way
+ this one did.
+- **The store's unguarded path is deliberately not ordered, and that is stated rather than left
+ silent.** While a field is unguarded the WS handler writes the payload value into the store
+ directly, and a refetch response may write a different value moments later. Both carry server
+ state, WS delivery on a single connection is ordered, and any disagreement is corrected by the
+ next event or refetch, so the window is bounded and self-correcting. No ordering guarantee is
+ claimed for the store entry while the field is unguarded and **no test should assert one**. The
+ `updated_at` ordering rule governs canonical recording, which is what the displayed value is
+ derived from; it is not extended to the store's unguarded fast path.
+- **Store rollback is field-scoped.** A failed title commit rolls back the store entry's `title`
+ under the same sequence rule **and to the same target** as the displayed title (section F,
+ "Rollback ordering": the currently recorded canonical value, AC-64), and rolls back nothing
+ else. Without this the task's card keeps showing a rejected, unpersisted
+ title permanently, because a failed write is never persisted and no `task.updated` is ever
+ published to correct it. Note that `useOptimisticTaskMutation` today restores the **whole**
+ `storeSnapshot` on failure, which would revert unrelated fields another writer changed in the
+ meantime; it must not be reused unchanged for this. AC-55 governs.
+- A manual title edit **clears any pending agent-generated title** for the task: `Service.UpdateTask`
+ deletes the `agent_title_pending` and `agent_title_owner_session_id` metadata keys whenever a
+ title is supplied, and the row is then written unconditionally. A human title always wins over an
+ agent title that has not landed yet. A description-only edit leaves the pending marker intact, and
+ the backend's conditional title write then applies; see section F.
+
+### F. Ordering, concurrency, and retries
+
+- **Ordering.** The two fields are independent single-column writes to one row; this feature
+ introduces no list, no sort, and no tiebreak. When several edits are committed in sequence, the
+ effective order is commit order, and the persisted `tasks.updated_at` is stamped **inside** the
+ write transaction (`repository/sqlite/task.go`, `updateTaskTx`), so its ordering matches true
+ serialization order.
+- **Concurrency, general case.** `Service.UpdateTask` is a read-modify-write: it loads the row,
+ applies the patch, and issues a full-row `UPDATE`. For every column **except** `title` and the
+ title-related metadata keys there is no version or `updated_at` precondition, so two writers
+ resolve **last-commit-wins**, and a write can carry a stale value for a field it did not intend to
+ change if a different writer committed inside the window between its read and its write. This is
+ pre-existing behaviour of the shared endpoint, it applies equally to every current caller, and
+ this feature does not change it. Adding general optimistic concurrency control is named out of
+ scope below.
+- **Concurrency, the title exception (do not assume last-commit-wins here).** `updateTaskTx` is not
+ a single statement. When the caller's in-memory snapshot still carries `agent_title_pending`, it
+ issues a **conditional** update whose title assignment is
+ `title = CASE WHEN THEN ? ELSE title END`, together with a JSON merge of the
+ title metadata. That is a genuine compare-and-swap against live database state, it exists so an
+ agent-generated title cannot overwrite a human one that landed first, and it is covered by
+ dedicated tests in `apps/backend/internal/task/repository/sqlite/task_title_cas_test.go`
+ (`TestSetTaskTitleIfPendingRequiresTrueMarker`, `TestSetTaskTitleIfPendingRejectsNonOwner`,
+ `TestUpdateTaskPreservesWinningTitleAgainstStaleUpdate`, `TestClaimTaskTitleSessionIsSingleOwner`).
+ `Service.UpdateTask` acknowledges this in a comment: the post-write snapshot may be stale precisely
+ because a conditional title patch was applied. **This feature exercises that path directly**: a
+ description-only save on a task whose agent title is still pending goes through the conditional
+ statement, and AC-48 requires it to be tested rather than assumed. Do not "simplify" this branch
+ and do not treat that window as an unguarded race already excluded from scope.
+- **Rollback ordering (overlapping title commits).** Title commits are sequenced. Each commit takes
+ a monotonically increasing sequence number when it is issued. A failed commit restores the title
+ **only if it is the last word standing**: only if there exists no later-sequenced commit that has
+ already resolved successfully **and** no later-sequenced commit that is still unresolved.
+ Otherwise the restore is skipped and the displayed title is left alone. The error is surfaced
+ as a toast either way, and the same condition governs the store rollback (section E).
+- **What a rollback restores: the field's currently recorded canonical value, read at the moment the
+ restore runs.** It is **not** a per-commit snapshot of whatever happened to be displayed when that
+ commit was issued. This definition is corrected from an earlier draft and the reasoning is
+ recorded here so it is not rediscovered the hard way. A value captured at issue time can be the
+ **optimistic value of an earlier commit that later failed**, a title the server never persisted;
+ restoring it puts the page into precisely the state AC-44's rationale says must never occur. A
+ canonical value cannot carry that defect by construction: section E records one only from the
+ initial load, a surviving refetch, or a **successful** write, so every canonical value is one the
+ server confirmed it holds. AC-64 governs. Two consequences, both load-bearing:
+ - **Restoring is idempotent.** Two failed commits that both restore land on the same value, so the
+ order in which they resolve cannot change the end state.
+ - **The restore and section E's deferred apply agree by construction, not by sequencing.** Both
+ write the same canonical value, so when a guard release and a rollback coincide it does not
+ matter which runs first. No ordering between them is specified, and none is needed.
+ The exclusions are load-bearing and none is redundant. The partition is over the state of
+ **later-sequenced** commits, and all four of its cases are named:
+ - **A later commit that already succeeded** (AC-44). Restoring here would silently revert a
+ newer successful commit, leaving a title that is neither what the user typed nor what is
+ stored.
+ - **A later commit still in flight** (AC-53). The displayed title currently shows that newer
+ commit's optimistic value. Restoring the older snapshot would overwrite it, and a commit
+ *succeeding* re-renders nothing on its own (AC-3 renders at issue time only), so the newer
+ commit's success would not correct the display. It would be corrected only later and only
+ indirectly, when a canonical value arrives and the deferred apply runs, which is at least an
+ event round-trip away and shows the user a title they did not type in the meantime. The
+ narrower rule that named only the already-resolved case therefore mandated a mismatch it was
+ written to prevent.
+ - **A later commit that has already resolved with FAILURE is not an exclusion**, and does not
+ block this commit's restore (AC-65). This is the fourth case and it is named explicitly rather
+ than left to be inferred from "neither succeeded nor still unresolved", because leaving it
+ implicit is what previously let a chained double failure land on a never-persisted title: under
+ the old snapshot-based restore target, the later commit's snapshot was the earlier commit's
+ optimistic value, so whichever of the two resolved last restored a title that had never been
+ stored, and the end state depended on network ordering alone. It is safe now because of the
+ corrected restore target above: both commits restore to the same canonical value, so the
+ second restore is a no-op rather than a revert, and the end state is the same whichever order
+ they resolve in.
+ - **No later-sequenced commit at all** is the ordinary single-commit case and restores (AC-9).
+- **Convergence after overlapping commits (both succeed).** Sequence numbers order the client's
+ rollbacks; they do not reorder the network. If commit 1 sends `B` and commit 2 sends `C` and the
+ requests reach the server out of order, the row keeps whichever write committed last, which may be
+ `B`, while the display shows `C`. This feature does **not** add ordering guarantees to the wire
+ and does not re-send to force a winner. It converges instead, and by a shorter path than an event
+ round-trip: **each write's own response records the row's value for that field as canonical**,
+ ordered by `updated_at` (section E), so by the time the later-resolving commit's response has
+ landed, the canonical value is already whichever write the row actually kept. Once the last commit
+ resolves the title is unguarded and the deferred-apply rule lands that value on the display. The
+ refetches driven by each `task.updated` are a second, redundant path to the same end state rather
+ than the mechanism convergence depends on. The end state is therefore the row's value, reached
+ without a page reload, and the window in which the display disagrees with the row is bounded by
+ the in-flight commits alone. AC-54 observes this. Users who care about which of two rapid renames
+ wins are asking for general optimistic concurrency control, which is named out of scope below.
+- **Late-arriving refresh.** A WS-driven refetch that lands while an editor is open must **not**
+ overwrite the user's draft, and must not overwrite an in-flight optimistic value either; the
+ per-field guard in section E states the full rule. The field's **canonical value** behind an open
+ editor may change, and is recorded when it does; what the guard suppresses is only its
+ **application to the displayed value**, and that application is deferred to the moment the editor
+ closes rather than dropped (section E, "Deferred apply on release"). Meanwhile the editor's
+ **baseline is frozen at open time** (section C for the description, section B for the title), so
+ Save, Cancel, and Escape behaviour never changes underneath the user as a result of somebody
+ else's edit. Those two statements are consistent because they are about different values: the
+ canonical value moves, the displayed value and the baseline do not.
+- **Idempotency.** A commit whose trimmed value equals the baseline or the current title issues no
+ request. Committing the same value twice is therefore a single write followed by a no-op, and
+ re-sending an identical `PATCH` converges on the same value: `title` and `description` are
+ assigned, not accumulated, so N identical requests leave the same row as one. It is idempotent in
+ that sense only. Each request is still a real write that re-stamps `updated_at` inside the
+ transaction and republishes `task.updated`, so duplicates are observable as extra events and
+ extra refetches even though the field value never moves. This feature adds no idempotency key and
+ no duplicate-request suppression, and needs neither: it issues no automatic retries, and a commit
+ equal to the baseline issues no request at all, so this client never sends a duplicate of its own.
+- **Retries.** No automatic retry on failure. A failed title commit rolls back subject to the
+ rollback-ordering rule above and toasts; a failed description save keeps the editor open with the
+ draft and toasts. In both cases the next attempt is a fresh user action.
+
+### G. Errors
+
+- Network failure, `5xx`, or a task that no longer exists (`404`) means the edit does not apply, the
+ prior value is restored (title) or the editor stays open with the draft (description), and the
+ message surfaces as a toast.
+- `400` from title validation is unreachable through the UI, because the input clamps at the same
+ 60-character limit the server enforces. If it is returned anyway, it is surfaced as a toast like
+ any other failure; no special-cased inline error is required.
+- The failure path never leaves an invisible unsaved draft. Either the editor is open and the draft
+ is visible, or the editor is closed and the visible text is the stored text.
+
+### H. Interaction details a builder would otherwise have to invent
+
+- **Keyboard parity.** Both affordances are reachable and operable without a pointer. The title
+ element and the description element (or its empty-state placeholder) are focusable, and Enter on
+ the focused element opens the corresponding editor. Pointer activation is double-click for the
+ title and single click for the description; the asymmetry is deliberate: a single click on a
+ paragraph of prose is how every comparable product opens a description, while a single click on a
+ page heading is not, and the title's double-click matches the existing Kanban rename.
+- **Click versus text selection.** A pointer interaction on the description that ends with a
+ non-empty text selection does not open the editor. Selecting and copying description text must
+ remain possible.
+- **The two editors are independent in state, but not symmetric about focus.** Neither editor ever
+ **commits** the other, and a dirty description draft never blocks a title commit (AC-35). They are
+ not, however, both freely open at once, and the asymmetry follows from blur:
+ - The description editor survives blur (AC-21), so it stays open with its draft while the user
+ opens, edits, and commits the title editor. That order is safe and AC-35 covers it.
+ - The title editor **cancels on blur** (AC-5). Opening the description editor necessarily moves
+ focus off the title input, whether by pointer or by keyboard, so the title editor closes and
+ its draft is discarded. AC-57 states this explicitly rather than leaving it to be discovered.
+ This is deliberate, not an oversight: the title editor is transient by design, matching the
+ existing Kanban rename, and section C's rationale explains why a lost title draft is cheap (one
+ line, at most 60 characters) while a lost description draft is not. A user who wants to keep a
+ title draft commits it with Enter first. If a future card wants both editors genuinely co-open,
+ that is a change to AC-5, not to this bullet.
+- **Overlapping title commits.** A second title commit issued while the first is still in flight is
+ allowed. Sequencing and the rollback rule are specified in section F under "Rollback ordering";
+ that rule, not a per-commit snapshot alone, is what guarantees a failure never reverts a newer
+ successful commit.
+- **Cancel confirmation shape.** The AC-19 confirmation is a modal with two choices: discard the
+ draft and close the editor, or return to the editor with the draft intact. Dismissing the modal by
+ any other means (Escape, backdrop, close button) returns to the editor with the draft intact. The
+ draft is only ever destroyed by the explicit discard choice.
+- **Save and Cancel placement.** Save and Cancel render below the textarea, Save first and styled as
+ the primary action, Cancel secondary. This is a fresh product decision for this feature, not a
+ port: `project-header.tsx` has a single Save button rendered **above** its textarea and **no
+ Cancel control at all**, so it is a precedent for gating Save on dirty state and for nothing else.
+- **Invalid-title feedback.** AC-7's invalid state is both programmatic and visible: `aria-invalid`
+ on the input plus a short visible message adjacent to it. It clears as soon as the draft becomes
+ non-empty.
+- **Stable test hooks.** The implementation exposes these `data-testid` values, and the E2E spec
+ addresses the feature through them rather than through copy:
+ `office-task-title`, `office-task-title-input`, `office-task-title-error`,
+ `office-task-description`, `office-task-description-empty`, `office-task-description-textarea`,
+ `office-task-description-save`, `office-task-description-cancel`,
+ `office-task-description-discard-confirm`, `office-task-description-running-note`.
+
+## Acceptance criteria
+
+Written in EARS form. Each is observable from the Office task detail page or from the persisted row.
+
+**Title**
+
+- AC-1: WHEN the user double-clicks the task title on the Office simple pane, THE SYSTEM SHALL
+ replace it with a focused single-line input seeded with the current title and its content
+ selected.
+- AC-2: WHEN the title element has keyboard focus AND the user presses Enter, THE SYSTEM SHALL
+ enter the same edit state as AC-1.
+- AC-3: WHILE the title editor is open AND the trimmed draft is non-empty AND differs from the
+ stored title, WHEN the user presses Enter, THE SYSTEM SHALL render the **trimmed** draft
+ immediately, send `PATCH /api/v1/tasks/:id` with a body containing only `title` set to that same
+ trimmed value, and close the editor.
+- AC-4: WHILE the title editor is open, WHEN the user presses Escape, THE SYSTEM SHALL discard the
+ draft, close the editor, restore the stored title, and send no request.
+- AC-5: WHILE the title editor is open, WHEN the input loses focus, THE SYSTEM SHALL behave exactly
+ as in AC-4.
+- AC-6: WHILE the title editor is open, WHEN the user enters or pastes text that would take the
+ field beyond 60 code points, THE SYSTEM SHALL retain only the first 60 and SHALL leave the caret
+ at the position the user was typing or pasting at, not at the end of the field.
+- AC-7: WHILE the title editor is open AND the trimmed draft is empty, WHEN the user presses Enter,
+ THE SYSTEM SHALL keep the editor open, mark the field invalid to assistive technology, and send no
+ request.
+- AC-8: WHILE the title editor is open AND the trimmed draft equals the stored title, WHEN the user
+ presses Enter, THE SYSTEM SHALL close the editor and send no request.
+- AC-9: IF the title `PATCH` fails AND no later-sequenced title commit has already resolved
+ successfully AND no later-sequenced title commit is still unresolved, THEN THE SYSTEM SHALL
+ restore the title's currently recorded canonical value (AC-64) and surface the error as a toast.
+ (Both exclusions are required: this is the "last word standing" condition from section F, and
+ AC-44 and AC-53 are the two cases it excludes. An earlier version of this criterion carried only
+ the first exclusion, which made it mandate a restore in exactly the situation AC-53 forbids. A
+ second earlier version restored a per-commit snapshot of the displayed title, which could be a
+ never-persisted value; AC-64 replaced that target and AC-65 observes the case that exposed it.)
+- AC-10: WHILE an IME composition is active, WHEN the user presses Enter, THE SYSTEM SHALL NOT
+ commit the title and SHALL leave the editor open.
+- AC-11: WHEN a title commit is **issued**, THE SYSTEM SHALL patch the task's entry in the Office
+ task store with the trimmed draft at that moment, before the request resolves, so list and board
+ surfaces show the new title without waiting for a WebSocket message. (Issue time, not success
+ time: AC-39's protection of that store entry during the in-flight window is only meaningful if
+ the entry already carries the unconfirmed value.)
+- AC-44: IF a title `PATCH` fails AND a later-sequenced title commit has already resolved
+ successfully, THEN THE SYSTEM SHALL leave the displayed title unchanged, SHALL NOT perform a
+ restore, and SHALL still surface the error as a toast.
+- AC-51: WHILE the title editor is open, THE SYSTEM SHALL use the title displayed at editor-open
+ time as the frozen baseline for AC-4's restore target and AC-8's equality test, AND SHALL NOT
+ change that baseline when a newer canonical title is recorded for the task.
+- AC-53: IF a title `PATCH` fails AND a later-sequenced title commit is still unresolved, THEN THE
+ SYSTEM SHALL leave the displayed title and the Office task store entry unchanged, SHALL NOT
+ perform a restore, and SHALL still surface the error as a toast.
+- AC-54: WHEN two overlapping title commits both succeed, THE SYSTEM SHALL converge the displayed
+ title on the task's stored value once no title write is in flight and no title editor is open,
+ without a page reload, regardless of the order in which the two requests reached the server.
+- AC-55: IF a title commit fails AND the rollback condition in section F is met, THEN THE SYSTEM
+ SHALL set the Office task store entry's `title` to the title's currently recorded canonical value
+ and SHALL leave every other field of that store entry at its current value.
+- AC-64: WHEN a failed title commit performs a restore, THE SYSTEM SHALL restore the title's
+ currently recorded canonical value read at the moment the restore runs, and SHALL NOT restore any
+ value captured from the display at commit-issue time.
+- AC-65: WHEN two overlapping title commits both fail, THE SYSTEM SHALL leave the displayed title
+ and the Office task store entry at the title's currently recorded canonical value, and SHALL reach
+ the same end state regardless of which of the two commits resolves first.
+
+**Description**
+
+- AC-12: WHEN the user clicks the task description, THE SYSTEM SHALL replace it with a focused
+ textarea seeded with the current description.
+- AC-13: WHERE the task has no description, THE SYSTEM SHALL render a persistent placeholder
+ affordance in the description's position, and WHEN the user clicks it, THE SYSTEM SHALL enter the
+ same edit state as AC-12 with an empty draft.
+- AC-14: WHILE the description editor is open AND the trimmed draft differs from the frozen
+ baseline, THE SYSTEM SHALL display an enabled Save control and a Cancel control.
+- AC-15: WHILE the description editor is open AND the trimmed draft equals the frozen baseline, THE
+ SYSTEM SHALL display Save in a disabled state.
+- AC-16: WHEN the user activates Save, THE SYSTEM SHALL send `PATCH /api/v1/tasks/:id` with a body
+ containing only `description` set to the trimmed draft, SHALL keep the editor open in a saving
+ state until the request resolves, and SHALL disable Save for the duration.
+- AC-17: WHEN a description save succeeds AND the current trimmed draft still equals the submitted
+ value, THE SYSTEM SHALL close the editor and render the saved value.
+- AC-18: IF a description save fails, THEN THE SYSTEM SHALL keep the editor open with the draft
+ unchanged and surface the error as a toast.
+- AC-19: WHILE the description editor is open AND the trimmed draft differs from the frozen
+ baseline, WHEN the user activates Cancel, THE SYSTEM SHALL require an explicit confirmation before
+ discarding the draft.
+- AC-20: WHILE the description editor is open AND the trimmed draft equals the frozen baseline, WHEN
+ the user activates Cancel, THE SYSTEM SHALL close the editor immediately without a confirmation.
+- AC-21: WHILE the description editor is open, WHEN the textarea loses focus, THE SYSTEM SHALL keep
+ the editor open, keep the draft, and send no request.
+- AC-22: WHILE the description editor is open AND the trimmed draft differs from the frozen
+ baseline, WHEN the user presses Escape, THE SYSTEM SHALL take no action.
+- AC-23: WHILE the description editor is open AND the trimmed draft equals the frozen baseline, WHEN
+ the user presses Escape, THE SYSTEM SHALL close the editor.
+- AC-24: WHEN a description save commits an empty trimmed draft on a task that previously had a
+ description, THE SYSTEM SHALL clear the stored description and SHALL then render the placeholder
+ affordance from AC-13.
+- AC-25: WHILE at least one session on the task is in state `RUNNING`, WHEN the description editor
+ is open, THE SYSTEM SHALL display a non-blocking note stating the change applies at the agent's
+ next wakeup, and SHALL NOT disable Save.
+- AC-40: WHILE the description editor is open, WHEN an `office.task.updated` refetch changes the
+ task's stored description, THE SYSTEM SHALL leave the editor's frozen baseline unchanged, so that
+ the enabled state of Save and the behaviour of Cancel and Escape do not change as a result.
+- AC-41: WHILE a description save is in flight, THE SYSTEM SHALL keep the textarea editable, SHALL
+ disable both Save and Cancel, and SHALL take no action on Escape.
+- AC-42: WHEN a description save succeeds AND the user changed the draft while the save was in
+ flight, THE SYSTEM SHALL keep the editor open with the newer draft intact, SHALL set the baseline
+ to the value that was saved, and SHALL re-enable Save while the newer trimmed draft differs from
+ it.
+- AC-43: WHERE the stored description is empty, WHILE the description editor is open AND the draft
+ contains only whitespace, THE SYSTEM SHALL treat the draft as equal to the baseline, SHALL keep
+ Save disabled, and SHALL send no request.
+- AC-45: WHEN the user saves a description of any length, THE SYSTEM SHALL send and persist it
+ unmodified apart from whitespace trimming, applying no client-side or server-side length limit.
+- AC-46: WHEN a description containing Markdown syntax is rendered, THE SYSTEM SHALL display it as
+ literal plain text with whitespace preserved, and SHALL NOT render it as formatted Markdown.
+- AC-56: WHEN a description save is issued, THE SYSTEM SHALL NOT patch the Office task store entry,
+ and WHEN that save succeeds, THE SYSTEM SHALL patch the store entry's `description` at that
+ moment, so card previews never show a description that has not been persisted.
+
+**Shared**
+
+- AC-26: WHEN either field is committed AND no other writer commits to the same task inside the
+ request window, THE SYSTEM SHALL send only the edited field in the request body and SHALL leave
+ every other task property unchanged in the response. This feature asserts no outcome when a
+ concurrent writer does commit inside that window: `Service.UpdateTask` is last-commit-wins for
+ every column except the existing title compare-and-swap (section F), this feature neither adds
+ nor removes that behaviour, and no test should assert a required winner. Section F describes what
+ the backend does today; it does not impose a new requirement here.
+- AC-27: WHEN another client edits the same task, THE SYSTEM SHALL update the displayed title and
+ description without a page reload: immediately on receipt of `office.task.updated` WHERE the
+ field is unguarded, and otherwise at the moment that field becomes unguarded, per the deferred
+ apply in AC-50. This criterion promises convergence without a reload; it does not promise
+ application during a guarded window. AC-28 and AC-38 govern the guarded window and take
+ precedence there. (An earlier version said only "on receipt", which contradicted the guard and
+ would have failed a correct implementation.)
+- AC-28: WHILE either editor is open, WHEN an `office.task.updated` refetch resolves for this task,
+ THE SYSTEM SHALL leave the open draft unmodified.
+- AC-38: WHILE a title commit is in flight AND the title editor has already closed, WHEN an
+ `office.task.updated` refetch for this task resolves, THE SYSTEM SHALL leave the optimistically
+ rendered title unchanged.
+- AC-39: WHILE a title commit is in flight, WHEN a refetch triggered by an unrelated mutation on the
+ same task resolves and carries a title predating that commit, THE SYSTEM SHALL leave both the
+ displayed title and the Office task store entry unchanged.
+- AC-49: WHEN a non-stale refetch for this task resolves, THE SYSTEM SHALL record its value for each
+ field as that field's canonical value, whether or not an editor is open for that field and
+ whether or not a write to that field is in flight.
+- AC-50: WHEN a field becomes unguarded, because its editor closed by any route or because its last
+ in-flight write resolved, THE SYSTEM SHALL apply that field's currently recorded canonical value
+ to the displayed value if the two differ, so that a remote edit received while the field was
+ guarded becomes visible without a page reload.
+- AC-52: IF a refetch response's request was issued before this client's most recent committed write
+ to a field, THEN THE SYSTEM SHALL discard that response for that field, neither recording it as
+ canonical nor applying it to the displayed value.
+- AC-58: WHEN a write to a field resolves successfully, THE SYSTEM SHALL record the value that
+ request persisted for that field, read from the response body, as that field's canonical value,
+ AND SHALL do so before releasing that field's guard.
+- AC-59: WHEN the task detail page completes its initial load, THE SYSTEM SHALL record the loaded
+ title and description as those fields' canonical values.
+- AC-60: IF a candidate canonical recording carries an `updated_at` strictly older than the
+ `updated_at` carried by the field's currently recorded canonical value, THEN THE SYSTEM SHALL
+ discard the candidate for that field, neither recording nor applying it, so that two refetches
+ resolving out of order leave the newer value in place.
+- AC-61: WHILE a field is guarded, WHEN an `office.task.updated` event is received, THE SYSTEM SHALL
+ leave that field's Office task store entry unchanged, INCLUDING when the store write originates in
+ the WebSocket handler rather than in the page's refetch path.
+- AC-62: IF a refetch was issued after a commit to a field AND its response carries an `updated_at`
+ older than the one recorded by that commit's own successful response, THEN THE SYSTEM SHALL
+ discard that response for that field.
+- AC-63: WHEN two candidate recordings for a field carry exactly equal `updated_at` values, THE
+ SYSTEM SHALL accept the later-arriving one, ordered by this client's request-issue sequence for
+ the task.
+- AC-66: WHEN comparing two candidate recordings' `updated_at` values, THE SYSTEM SHALL parse each
+ to an absolute instant before comparing, SHALL accept both the space-separated offset-bearing
+ layout emitted by the Office task DTO and the RFC3339Nano layout emitted by a write response, and
+ SHALL NOT compare the values as raw strings.
+- AC-67: IF a candidate recording's `updated_at` cannot be parsed to an absolute instant, THEN THE
+ SYSTEM SHALL discard that candidate for that field, neither recording it as canonical nor applying
+ it to the displayed value, and SHALL NOT treat it as newer than the recorded value.
+- AC-68: WHEN the task detail page unmounts or the route changes away from it, THE SYSTEM SHALL end
+ the open-editor contribution to the guard for both fields, so that a field with no unresolved
+ write becomes unguarded.
+- AC-69: WHEN a write resolves after the task detail page has unmounted, THE SYSTEM SHALL record its
+ canonical value per AC-58, SHALL end that write's contribution to the guard, and SHALL leave no
+ guard entry in place for a field that has no open editor and no unresolved write.
+- AC-70: WHEN a write to a field fails, THE SYSTEM SHALL end that write's contribution to the
+ field's guard exactly as it does for a write that succeeds.
+- AC-71: WHEN a canonical value is recorded for `description`, THE SYSTEM SHALL normalise an absent
+ value (`undefined`, `null`, or an omitted field) to the empty string before recording it, so that
+ a guard release on a task with no description applies nothing.
+- AC-72: WHEN a field has no recorded canonical value, THE SYSTEM SHALL record the next candidate
+ for that field unconditionally, applying neither the staleness rule, nor the `updated_at` ordering
+ rule, nor the parse rule to it.
+- AC-73: IF a field's recorded canonical value carries an `updated_at` that could not be parsed,
+ THEN THE SYSTEM SHALL accept the next candidate whose `updated_at` does parse, superseding the
+ recorded value.
+- AC-29: WHEN a title is committed on a task carrying a pending agent-generated title, THE SYSTEM
+ SHALL persist the user's title and SHALL clear the task's pending-agent-title metadata.
+- AC-48: WHEN a description-only save is committed on a task carrying a pending agent-generated
+ title, THE SYSTEM SHALL persist the new description, SHALL leave the pending-agent-title metadata
+ intact, and SHALL leave the stored title unchanged.
+- AC-30: THE SYSTEM SHALL offer the title and description editors regardless of the task's status
+ and regardless of any session state, introducing no status-based or lifecycle-based gate.
+- AC-47: THE SYSTEM SHALL NOT introduce an archived-task gate on either editor, and SHALL NOT port
+ the `isArchived` condition from `apps/web/components/task/task-top-bar-title.tsx`.
+- AC-31: THE SYSTEM SHALL NOT queue an agent run, wake an agent, or interrupt a running session as
+ a result of a title or description edit.
+
+**Interaction details**
+
+- AC-33: WHEN the title element or the description element has keyboard focus AND the user presses
+ Enter, THE SYSTEM SHALL open the corresponding editor.
+- AC-34: WHEN a pointer interaction on the description ends with a non-empty text selection, THE
+ SYSTEM SHALL NOT open the description editor.
+- AC-35: WHILE the description editor is open with a dirty draft, WHEN the user opens and commits
+ the title editor, THE SYSTEM SHALL commit the title and SHALL leave the description editor open
+ with its draft intact.
+- AC-36: WHILE the AC-19 confirmation is displayed, WHEN the user dismisses it by any means other
+ than the explicit discard choice, THE SYSTEM SHALL return to the description editor with the draft
+ intact.
+- AC-37: WHILE the title editor is open AND the trimmed draft is empty, THE SYSTEM SHALL mark the
+ input `aria-invalid` and display a visible message, and SHALL clear both as soon as the trimmed
+ draft is non-empty.
+- AC-57: WHILE the title editor is open, WHEN the user opens the description editor, THE SYSTEM
+ SHALL close the title editor, discard its draft, restore its frozen baseline title, and send no
+ request.
+
+**Documentation reconciliation**
+
+- AC-32: `docs/specs/office/tasks.md` SHALL be amended so that its editable-property list at
+ `### D. Inline editable properties` names Title and Description, and its `PATCH /tasks/:id` entry
+ under `## API surface` states that title and description are edited through
+ `PATCH /api/v1/tasks/:id` and are not fields of the Office endpoint. No other behaviour in that
+ document changes.
+
+## Surfaces touched
+
+- `apps/web/components/task/simple/OfficeSimplePane.tsx`: the `
` at line 512 and the
+ description block at lines 513-517. The component is 574 lines against a 600-line lint ceiling, so
+ the editors are expected to be extracted into sibling components rather than added inline.
+- **AC-25's data source is already in scope**: `OfficeSimplePaneProps` declares
+ `sessions: TaskSession[]`, and `TaskSession["state"]` already includes `"RUNNING"`. No new fetch,
+ prop, or store field is required for the running-session note.
+- `apps/web/hooks/use-optimistic-task-mutation.ts`: `toOfficeTaskPatch` must carry `title` and
+ `description`, and the section E rules apply to the store patch it writes. **This hook does not
+ fit both fields as it stands, and that is expected, not a defect to work around.** Its only shape
+ is apply-patch-then-await: it calls `ctx.applyPatch` and `patchTaskInStore` **before**
+ `await apiCall()`, and on failure restores the **whole** `storeSnapshot`. That matches the title
+ (optimistic at issue time, AC-11) and does **not** match the description (store patch on success
+ only, AC-56), and its whole-object rollback is wrong for both (AC-55 requires a field-scoped
+ restore). Build is expected to reshape or extend the hook rather than silently accept pre-await
+ timing for the description; which of the two it is is an implementation decision, but the timings
+ in AC-11 and AC-56 and the field-scoping in AC-55 are not.
+- `apps/web/app/office/tasks/[id]/page.tsx`: `refetchTask` replaces the whole task object, so the
+ per-field in-flight guard from section E is applied here. It carries no request ordering of any
+ kind today, and `useOfficeRefetch` (`apps/web/hooks/use-office-refetch.ts`) has no debounce, no
+ coalescing and no in-flight cancellation, so concurrent refetches for one task are the normal case
+ rather than an edge case. That is why AC-60 exists.
+- **`apps/web/lib/ws/handlers/office.ts`: the second store writer.** `normalizeIssueFields` (the
+ `p.title` / `p.description` mapping) and `updateTaskStatus` (the raw `store.setState`) are both
+ in scope, because together they write these two fields into the Office task store outside
+ `patchTaskInStore`. AC-61 governs. Prior art for the ordering half of section E, worth reading
+ before implementing it: `nextRequestSequence` / `isLatestRequest` in
+ `apps/web/hooks/use-office-workspace-data.ts` already give every other Office loader
+ last-request-wins, for exactly this reason.
+- `apps/web/lib/api/domains/kanban-api.ts`: `updateTask()`, used as-is. Note its return value is now
+ load-bearing: it resolves to the updated `Task`, and AC-58 records that response's value for the
+ written field as canonical. A call site that discards the response cannot satisfy AC-58.
+- `docs/specs/office/tasks.md`: reconciliation per AC-32.
+- i18n: new copy in `en` plus `pt-pt`, `zh-cn`, `zh-hk`, `zh-tw`. `common:save`, `common:cancel`,
+ `task:description`, `task:taskTitle`, `task:saving`, `task:updateFailed` and
+ `office:addADescription` already exist and should be reused rather than duplicated. One trap:
+ `task.json` carries **both** `description` (value `"description"`, lowercase, used today as an
+ inline `KeyValueRow` label) and `description2` (value `"Description"`, capitalized). Pick by the
+ element: a standalone visible label wants `task:description2`, an inline or aria-only string
+ wants `task:description`. Do not add a third key for the same word.
+
+No backend file changes are required by this spec. AC-48 adds a test over existing backend
+behaviour; it does not change it.
+
+## Test expectations
+
+Every acceptance criterion has a declared verification below. AC ids are enumerated
+individually rather than as ranges, so coverage can be checked mechanically: every id from AC-1 to
+AC-73 appears at least once in this section.
+
+- **Unit (vitest)**, alongside `apps/web/components/task/simple/OfficeSimplePane.test.tsx`:
+ - Title editor open / commit / cancel / blur / clamp / paste-clamp / empty / unchanged / IME paths:
+ AC-1, AC-2, AC-4, AC-5, AC-6, AC-7, AC-8, AC-10, AC-33, AC-37.
+ - Title trim on send and on optimistic render: AC-3.
+ - Title rollback, **all four branches** of the sequence rule, one test each: the
+ last-word-standing case with no later commit at all (AC-9); a later commit that already
+ succeeded (AC-44); a later commit still unresolved (AC-53); and a later commit that already
+ resolved with **failure**, which restores (AC-65). AC-53 is the branch whose absence previously
+ made AC-9 mandate the wrong outcome, and the fourth branch is the one whose absence let a
+ chained double failure land on a never-persisted title, so both are asserted explicitly and
+ neither is folded into AC-9's test.
+ - **The rollback target** (AC-64): a failed commit restores the title's currently recorded
+ canonical value, not a value captured from the display at commit-issue time. Assert this
+ directly, with a canonical value that differs from what was displayed when the commit was
+ issued, so a snapshot-based implementation fails.
+ - **The chained double failure, asserted in both resolution orders** (AC-65). Two overlapping
+ title commits, both failing: assert the displayed title and the Office task store entry both end
+ at the canonical (pre-chain, actually stored) title when the earlier commit resolves first, and
+ again when the later one does. This is the regression test for the defect the four-branch
+ partition and AC-64 were written to close; a test that runs only one resolution order would have
+ passed against the broken spec.
+ - Office task store patch mapping, and the timing split between the two fields: AC-11 (title
+ patched at commit-issue time, asserted before the request resolves) and AC-56 (description not
+ patched at issue time, patched on success).
+ - Field-scoped store rollback on a failed title commit: AC-55 asserts the store entry's `title`
+ is restored and that a second field of the same store entry, changed in the meantime, is not.
+ - The title's frozen baseline under a canonical update: AC-51.
+ - Description open / placeholder / dirty / Save / Cancel / confirm / blur / Escape paths: AC-12,
+ AC-13, AC-14, AC-15, AC-16, AC-17, AC-18, AC-19, AC-20, AC-21, AC-22, AC-23, AC-24, AC-34,
+ AC-36.
+ - Frozen baseline under a refetch: AC-40. Draft survives a refetch while open: AC-28.
+ - In-flight control state and in-flight editing: AC-41, AC-42.
+ - Whitespace-only draft on an empty description: AC-43.
+ - No length limit and plain-text rendering: AC-45, AC-46.
+ - Running-session note: AC-25.
+ - In-flight guard for the optimistic title, editor closed and unrelated-mutation refetch:
+ AC-38, AC-39.
+ - The canonical/displayed rules from section E, one test each: recording is never suppressed
+ (AC-49); the deferred apply fires when the guard releases, asserted through the case that
+ motivated it, a remote description edit arriving during an open editor that the user then
+ cancels (AC-50); a response whose request predates the local write is discarded rather than
+ recorded (AC-52).
+ - **The three record triggers, enumerated in section E.** The initial load records both fields
+ (AC-59). A successful write records the value its own response persisted, asserted **before**
+ the guard releases (AC-58). The regression this pair exists to prevent is asserted directly and
+ on **both** fields: with no refetch in play at all, a successful title commit and a successful
+ description save each leave the displayed value at the saved text after the guard releases,
+ rather than reverting to the pre-write value.
+ - **The `updated_at` ordering rule**, one test per branch: two refetches resolving out of order
+ leave the newer value in place (AC-60); a refetch issued after a commit but carrying an older
+ `updated_at` than the commit's own response is discarded (AC-62); two candidates with exactly
+ equal `updated_at` resolve by request-issue sequence (AC-63).
+ - **Cross-format parsing** (AC-66). The fixtures must be the **two layouts that actually occur**,
+ not two variants of one: a refetch recording in the Office task DTO's raw SQLite form
+ (`2026-08-20 14:30:05.123456789+00:00`, space-separated and offset-suffixed) and a
+ write-response recording in `RFC3339Nano` (`2026-08-20T14:30:05.123456789Z`). Assert the
+ ordering comes out right in both directions. A raw string comparison must fail this test, and
+ so must an implementation that parses only the `T`/`Z` form, and so must one that hands the
+ space-separated form to `new Date(...)` and assumes local time. AC-60's earlier prescribed
+ fixtures (one RFC3339Nano value with a stripped trailing zero and one at second resolution)
+ exercised only
+ the same-format hazard and would have passed against a parser that is wrong for the majority of
+ real comparisons; keep one such pair as an additional case, but it is not the primary one.
+ - **The seed rule** (AC-72): with no canonical value recorded for a field, the next candidate is
+ recorded even when it would fail the staleness, ordering or parse rule, so a failed commit
+ always has a restore target (AC-64). And (AC-73) where the seed's `updated_at` did not parse,
+ a later candidate whose `updated_at` does parse supersedes it, so the field is not pinned to
+ the seed for the lifetime of the page.
+ - **Unparseable `updated_at`** (AC-67): a candidate whose timestamp does not parse is discarded
+ for that field, the previously recorded canonical value survives unchanged, and the candidate is
+ **not** treated as newest. Assert the surviving value explicitly, because a "fall back to now"
+ implementation passes any test that only checks the candidate was not recorded verbatim.
+ - **Guard lifetime** (AC-68, AC-69, AC-70). Unmounting the detail page while an editor is open
+ leaves the field unguarded, so a subsequent `office.task.updated` reaches the store entry
+ (AC-68). A write that resolves after unmount still records its canonical value and still ends
+ its contribution, and the store entry ends at the persisted value (AC-69). A **failed** write
+ ends its contribution exactly as a successful one does, asserted by showing that a later event
+ reaches the store entry after the failure (AC-70). These three are the tests that would catch a
+ guard implemented as an independently-mutated flag rather than derived from its two facts.
+ - **Absent-description normalisation** (AC-71): on a task with no description, a guard release
+ applies nothing and fires no store patch, with the load path supplying `description: undefined`
+ and the write path supplying `""`. Without normalisation the two compare unequal and the test
+ sees a spurious apply.
+ - **The WebSocket handler is subject to the guard** (AC-61): with a title commit in flight, an
+ `office.task.updated` carrying the pre-commit title leaves the Office task store entry
+ unchanged. This test must drive the real `office.ts` handler path (`normalizeIssueFields` plus
+ `updateTaskStatus`), not `patchTaskInStore`, or it asserts nothing.
+ - Convergence after two overlapping title commits that both succeed, with the responses
+ delivered out of order: AC-54.
+ - Both editors independent, title commits while description draft is dirty: AC-35. **And the
+ other order**, which is the asymmetric one: opening the description editor while the title
+ editor is open closes the title editor and discards its draft (AC-57).
+ - No status or lifecycle gate, and no archived gate: AC-30, AC-47.
+ - Only the edited field is sent, single-writer case: AC-26.
+- **Backend (Go)**, alongside `apps/backend/internal/task/service/`:
+ - A description-only update on a task carrying a pending agent title leaves the pending metadata
+ and the stored title intact: AC-48. This exercises the conditional title statement in
+ `updateTaskTx` described in section F.
+ - A title update on a task carrying a pending agent title persists the human title and clears the
+ pending metadata: AC-29.
+ - A title or description update publishes `task.updated` **without** `assignee_agent_profile_id`
+ on the payload, so `queueTaskAssignedRun` cannot queue a run: AC-31. This is the regression test
+ that makes AC-31 a contract rather than a code-reading, and it fails the moment somebody adds
+ that field to `publishTaskEventNow`.
+- **E2E (Playwright)**, a new spec alongside `apps/web/e2e/tests/office/property-pickers.spec.ts`:
+ - Editing the title and the description on a real seeded Office task, asserting both the rendered
+ value and the persisted row: AC-3, AC-16, AC-17, AC-26.
+ - A second browser context observing the edit converge without a reload: AC-27.
+ - One route-mocked failure asserting title rollback: AC-9.
+ - One route-mocked failure asserting the description editor survives with its draft intact: AC-18.
+- **AC-32** is a documentation change with no runtime behaviour. It is verified by inspection of
+ `docs/specs/office/tasks.md` in the same pull request; Build must confirm both edits landed.
+
+## Out of scope
+
+Each exclusion below is a decision, not an omission.
+
+- **General optimistic concurrency control on task writes.** `Service.UpdateTask` keeps
+ last-commit-wins for every column except the title compare-and-swap that already exists and is
+ described in section F. Adding a version column or an `updated_at` precondition for the remaining
+ columns would alter every existing caller of the endpoint and belongs in its own card. This
+ feature neither adds nor removes concurrency control in the backend.
+- **Guaranteeing which of two rapid renames wins.** Sequence numbers order this client's rollbacks;
+ they do not order the network, and this feature adds no wire ordering, no re-send, and no
+ server-side arbitration. Two overlapping title commits resolve to whichever write the server
+ committed last, and the display converges on it (AC-54). A caller who needs a defined winner is
+ asking for the concurrency control excluded above.
+- **Description version history, undo, or drafts persisted across page loads.** Explicit Save exists
+ precisely because none of these do. A draft lives in component state and is lost on navigation or
+ reload.
+- **Markdown rendering or a rich-text editor for the description.** The field stays plain text with
+ preserved whitespace, exactly as it renders today. AC-46 pins this.
+- **Extending `PATCH /api/v1/office/tasks/:id`** to accept title or description. The Office endpoint
+ and its `hasAnyField()` guard are unchanged, and a `{"title": "..."}` body sent to it continues to
+ return `400 "no mutation fields supplied"`.
+- **Waking or re-prompting the assigned agent on a content edit.** Deliberately not done; see
+ section D. Making a description edit re-brief a running agent is a product decision with its own
+ failure modes and needs its own spec.
+- **A minimum or maximum length for the description**, and any change to the 60-character title
+ limit.
+- **Editing title or description anywhere else in Office**: the task list, board cards, the sidebar,
+ the inbox, and the advanced / dockview mode are untouched.
+- **The sidebar property pickers.** They auto-save optimistically by design
+ (`docs/specs/office/tasks.md:60`) and are E2E-proven. No Save button is added to them.
+- **The blank Created by / Started / Completed rows.** Tracked separately.
+- **An archived-task gate.** Not an exclusion of behaviour but of a gate: the Office detail page
+ renders no archived state and the backend enforces no archived precondition, so no gate is added
+ and the Kanban `isArchived` condition is not ported. See section A and AC-47. Introducing archived
+ semantics to this page is separate work.
diff --git a/docs/specs/office/system-design/tasks-01.md b/docs/specs/office/system-design/tasks-01.md
index 57b13619fd..db70320d9c 100644
--- a/docs/specs/office/system-design/tasks-01.md
+++ b/docs/specs/office/system-design/tasks-01.md
@@ -70,7 +70,7 @@ This spec consolidates the office task surface: lifecycle, parent/child handoffs
### D. Inline editable properties
-- Every property row that holds an enumerable, lookup-able, or free-text value is editable inline by clicking the value: Status, Priority, Assignee, Project, Parent task, Blocked by, Reviewers, Approvers, Labels.
+- Every property row that holds an enumerable, lookup-able, or free-text value is editable inline by clicking the value: Status, Priority, Assignee, Project, Parent task, Blocked by, Reviewers, Approvers, Labels, Title, Description.
- Read-only rows stay read-only: Created by, Started, Completed, Created, Updated, Tree cost metrics. They reflect events, not user intent.
- Edits are **optimistic**: the new value renders immediately; on API failure the row reverts to the prior value and a toast surfaces the error.
- Pickers are searchable when the candidate list could exceed roughly 10 items (Assignee, Project, Parent, Blocked-by). Status and Priority pickers use a fixed list. The threshold for switching to a search input is around 8 candidates.
@@ -241,7 +241,7 @@ Multiple rows per `(task, decider, role)` in `office_task_approval_decisions` ar
- `POST /tasks/:id/approve { comment?: string }` - caller approves. 403 if caller is not in reviewers or approvers. Returns the created decision row.
- `POST /tasks/:id/request-changes { comment: string }` - caller requests changes. Comment required. Same 403 rule. Returns the decision row.
- `GET /tasks/:id/decisions` - current (non-superseded) decisions. Also surfaced on the task detail endpoint as a `decisions: TaskDecision[]` field.
-- `PATCH /tasks/:id` - inline property edits (status, priority, assignee, project, parent, blocked-by, reviewers, approvers, labels). Returns the updated task.
+- `PATCH /tasks/:id` - inline property edits (status, priority, assignee, project, parent, blocked-by, reviewers, approvers, labels). Returns the updated task. Title and description are not fields of this endpoint; they are edited through `PATCH /api/v1/tasks/:id`.
- `POST /tasks/:id/blockers { blocker_task_id }` - returns 400 with a body carrying the cycle path when the addition would create a cycle.
- `DELETE /tasks/:id/reviewers/:agentId` / `DELETE /tasks/:id/approvers/:agentId` - removes the participant AND terminates that agent's session for the task.
diff --git a/docs/specs/spec-lint.json b/docs/specs/spec-lint.json
index 278803b134..bc1338e48c 100644
--- a/docs/specs/spec-lint.json
+++ b/docs/specs/spec-lint.json
@@ -10,6 +10,7 @@
"template": 8192
},
"legacy_size_exceptions": {
+ "docs/specs/office-task-content-editing/spec.md": 84103,
"docs/specs/task-cost-ledger/spec.md": 91931
}
}