537496f7bb
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
1038 lines
82 KiB
Markdown
1038 lines
82 KiB
Markdown
---
|
|
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/<id>`, 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:<id>")` 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 <live pending marker> 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 `<h1>` 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.
|