Files
Rdmclin2 8fbb091050 ✨ feat(task): record assignment, status, priority and automation changes in the activity feed (#19258)
* ✨ feat(task): record assignment changes in the task activity feed

Assigning a task to a member or an agent only rewrote a column on `tasks`,
so the detail page's activity feed had no way to show who moved it or when
— a reader saw "created the task" and nothing else, however many times the
task had changed hands.

Every write path already funnels through the `task.update` procedure (the
member/agent pickers, the `editTask` builtin, the CLI), so the diff between
the pre- and post-update assignee columns is taken there and appended to
`task_activities`. Only a slot that actually moved is written, so re-saving
the same assignee stays silent. Unlike the assignment notification beside
it, the write is awaited rather than deferred: the client refetches the
detail as soon as the mutation resolves, and a row written after the
response would miss that refetch.

`updateSchema` gains an `actorAgentId` used solely for attribution, so an
agent reassigning through its tool is recorded as the agent rather than the
human whose session it borrowed.

On the render side `ActivityAuthor` is extracted so the actor and the
assignment target share one avatar/name/tag treatment, and `AssignmentRow`
reads as "<actor> assigned the task to <target>" / "<actor> removed the
agent". A target whose account no longer resolves keeps a stub author and
renders as deleted, because collapsing it to null would turn a reassignment
into a removal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 🌐 chore(i18n): sync generated locales

Runs `bun run i18n` so the 16 generated locales pick up the task assignment
activity strings instead of falling back to English until the daily workflow
catches up.

The run also flushes locale drift that had accumulated for other namespaces
(auth / device / modelProvider / models / providers) and realigns two
hand-edited en-US / zh-CN values with their source in
`packages/locales/src/default/chat.ts`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 🔒 fix(task): make assignment attribution unforgeable and atomic

Addresses three findings from review on the assignment activity log.

**Attribution came from the request payload.** `updateSchema.actorAgentId` was
public input, so any authorized client could name an arbitrary agent and have
the durable feed claim that agent made the reassignment — while suppressing the
real user. The field is gone; the agent now rides `AuthContext.actingAgentId`,
set only by a server-side `createCaller` from an already-authorized run
context, following the contract `spendOrigin` already established.

**The diff was read outside the write.** The previous assignee was read by the
router before the update and recorded after it, so concurrent reassignments of
one task could both observe the same origin: a racing A→B and A→C persist A→B
then B→C while the log claims A→B and A→C, losing the middle state, and the
rows could be ordered differently from the writes. `updateWithAssignmentLog`
now derives the previous value from a `FOR UPDATE` read and appends the events
in the same transaction as the update.

**One writer bypassed the recorder.** `TaskRunnerService` persists the inbox
agent when an unassigned task first runs, straight through `taskModel.update`.
The assignee chip visibly changed with nothing in the feed to explain it; that
write now goes through the same path, with neither actor column set since
nobody requested it — the feed renders those as the system.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 🌐 fix(task): make assignment attribution reach the router, and let locales order the sentence

Round-2 review follow-ups on the assignment activity log.

**The attribution fix was inert.** The caller built with `actingAgentId` in the
runtime factory is replaced by `ensureModels()` before any exported method
runs, and the replacement omitted it — so every agent-driven reassignment was
still recorded as the session user. The previous test missed it by building
`createTaskRuntime` directly with a ready-made caller, never exercising the
registration lifecycle; the new test drives `taskRuntime.factory` and asserts
on the caller the wrapped method actually uses.

**The row could not be translated.** Actor, verb and target were three DOM
nodes in a fixed order, so a verb-final language could not put the target
before the verb — Japanese rendered as a complete sentence with the target
stranded after it. The four verb strings are now full sentence templates with
`<actor/>` / `<target/>` placeholders rendered through `<Trans>`, so each
locale decides where the participants go. The generated locales had to be
dropped and retranslated: the keys kept their old fragment values otherwise,
since the script only fills in missing ones.

**The agent prompt claimed false provenance.** An actorless row is the runner's
system fallback, which the feed labels "System" but the prompt rendered as
"someone".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 🐛 fix(task): tell absent, invisible and unnamed participants apart

Round-3 review follow-ups. All three findings come from the same mistake:
collapsing distinct identity states onto one fallback.

**An invisible agent was credited to the system.** `getAgentAvatarsByIds`
filters by `ownership()`, so another member's private agent does not resolve
for the current viewer. The actor lookup returned `undefined`, and both the row
and the prompt reserve an absent author for the system — so an agent's
reassignment was durably shown as a system action. The actor now keeps a stub
carrying `unresolved: true`, as the assignment target already did.

**A live participant could borrow a deletion label.** `users.full_name` and
`agents.title` are both nullable, so a resolved row with an empty name fell
through `||` onto "System" or "a deleted agent". Display names now fall back to
the other column each query already returns (`title || name`,
`fullName || username`), and a resolved-but-nameless participant renders as
"Unnamed" rather than as gone.

**Two formatters dropped the event.** The `viewTask` tool prompt and the CLI
task detail have their own activity branches and neither knew about
assignments, so the event vanished on those surfaces. Both render it now,
through a shared `assignmentParticipantLabel` on the prompt side; the CLI keeps
a three-line local copy rather than take a dependency on `@lobechat/prompts`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* ⚡️ perf(task): show the assignment activity row in the same beat as the chip

Picking a new assignee flipped the chip instantly (that dispatch was already
optimistic) but the activity row explaining it only appeared after two
serial round-trips: the mutation, then the full task-detail refetch — a
fifteen-step aggregation that alone runs one to three seconds against a
remote database. The chip said "done" while the feed stayed silent.

The row is now synthesized in the same optimistic dispatch. The picker hands
over the chosen item's display metadata, since it is the one place that
already holds it — the store deliberately does not resolve members itself,
that source being a business-layer hook. The actor is the signed-in user;
with no known identity no row is invented.

Only a slot that actually changes produces a row, mirroring the server's own
rule, and the refetch that follows the mutation replaces the whole activity
array, which retires the synthetic rows with no reconciliation. A failed
mutation is rolled back by that same refetch. Comments share the identical
two-round-trip lag and are left as they were.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* ⚡️ perf(task): show a sent comment in the feed before the round-trip returns

Comments had the same two-round-trip lag the assignment row just lost: the
post, then the full task-detail refetch, before anything appeared. Sending
now synthesizes the row in the same dispatch, authored as the signed-in user
and carrying the topic it replies to, so it lands in the feed the moment the
button is pressed.

Attachments are deliberately left to the refetch — their metadata only exists
once the server has resolved the file ids. A comment posted on an agent's
behalf through the client-first runtime is also left to the refetch, since
the store has no agent identity to attribute it to.

While the synthesized row is on screen its edit / delete menu is withheld —
the server has never seen that id, so either action would only surface an
error. A failed post is rolled back by the same server-truth refetch the
assignment path uses, and the failure still reaches the caller.

The two builders now share one module and one id prefix, so a renderer has a
single predicate for "not yet persisted".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* ✨ feat(task): log status, priority and automation changes made by a person

The activity feed now explains every property a person can change from the
task's property bar, not only the assignee. A status pick, a priority pick and
turning automation on or off each leave a row: "<actor> changed the status to
<tag>", "set the priority to <tag>", "set automation to <summary>".

Only person-made changes are recorded — those made from the pickers or by an
agent acting through its tool. The runner, lifecycle and watchdog transitions
(backlog → running → completed) are deliberately not: the run row already
carries them, and a duplicate line per run would be noise. The policy is not a
flag; it is which writer a caller uses. Person-made edits go through the new
`TaskModel.updateWithLog`, the system transitions keep the plain writers.

`updateWithLog` replaces the per-property methods with one transactional
diff over a tracked column set (assignees, status, priority and the four
automation columns), so adding a tracked field is one list entry. The four
automation columns fold into a single event carrying before / after
snapshots: switching a schedule on rewrites mode, pattern and timezone in one
save, and that is one decision, not three.

All three task-detail formatters (page prompt, `viewTask` tool prompt, CLI)
render the new event, through a shared `formatPropertyValue` on the prompt
side.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* ✨ feat(task): fold a burst of edits to one property into a single feed entry

Fiddling with a property — priority High → Low → High inside a minute —
produced one line per hop, where an issue tracker shows "moved from A to C"
and shows nothing when the value ended up back where it started.

Collapsing happens at read time, in the detail assembly, so the rows in
`task_activities` stay append-only: the hop-by-hop history is still there for
audit or a "show all" view, and every consumer of the detail (page, `viewTask`
prompt, CLI) gets the same folded view. A run is one property by one person
with no gap over the window; it keeps the newest row's time (when it settled),
the first row's starting value and the last row's ending value. Two people
never merge — A moving it and B moving it back are two decisions, not a
cancelled one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 💄 style(task): say where a property moved from, not only where it landed

"changed the status to In Review" hides the half of the story a reader
actually wants once a burst of edits has been folded into one entry: an
entry can now span several hops, and the starting value is what says how far
the property moved. Status, priority and automation rows read "from A to B";
automation keeps its one-sided forms for turning on ("set automation to")
and off, since there is no other side to name.

The two reworded keys had to be deleted from the generated locales and
retranslated — the script only fills in missing keys.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 🐛 fix(task): surface every person-made property change in the feed at once

Three findings from driving the real UI:

**Automation changes never reached the feed.** `setAutomationMode` deliberately
does no post-save refetch — an async SWR write could land after the user's
next toggle and clobber it — so the row the server appended only showed up on
the next unrelated refresh. The row now rides the same optimistic patch as the
automation fields, inside the same engine transaction, so a failed save rolls
it back with them.

**Status and priority rows arrived a round-trip late.** Only assignment and
comment rows were synthesized; the other two waited for the detail refetch,
which against a remote database is the same lag the assignee row was fixed
for. Both now ride the optimistic dispatch that flips their chip.

**Priority read as "from ··· to !".** The priority tag alone is an icon; in a
sentence it is noise. The level's name now rides along.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 🐛 fix(task): attribute client-runtime edits, tighten activity collapse and rollback

- update/updateStatus accept an ownership-checked actorAgentId for the
  client-first runtime; the server-side actingAgentId still wins
- collapse only extends a run when each hop starts where the last ended
- optimistic property rows fold client-side; schedule edits get a row
- addComment drops its synthesized row locally when the send fails
- payload.actorKind keeps a deleted actor from reading as the system
- task detail reads a bounded window of recent activity rows

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 🐛 fix(task): log cascades and rolled-back starts, quieter activity rows

- collapse: any edit by someone else ends other actors' open runs
- updateStatusCascade logs one status row per family member the
  confirming person cascaded; the router passes the actor
- a person-made start rolled back after the heartbeat tick fails logs
  the undo too, so no orphan row survives the restore
- activity rows read like an issue tracker's: 16px mark, plain names,
  values as words instead of chips, agent chip dropped

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 🐛 fix(task): lock cascade origins, batch their rows, widen the schedule value

- cascade reads each task's leaving status under the row lock and writes
  the family's activity rows in one INSERT
- the automation value includes the execution cap and its labels show
  timezone and cap, so a cap-only or timezone-only edit is visible
- a failed status transition drops its synthesized row locally before
  the best-effort refetch

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 💄 style(task): lay the activity feed out as a timeline

Compact rows lead with a type mark on a shared rail instead of the
actor's avatar, read as one sentence in one tone, and sit tight together;
cards still break the rail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 💄 style(task): faces for people rows, property icons for property rows

The rail mark is the actor's avatar on created / assignment lines and the
property's icon (three-bar priority, status dot, timer) on change lines.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 💄 style(task): priority rows use the app's solid priority bars

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

* 💄 style(task): centre the activity rail under the cards' avatar column

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 17:53:00 +08:00
..