feat: add durable agent activity preambles#27
Conversation
Add a message `kind` discriminator ('message', 'agent_commentary',
'agent_tool') and a nullable `turn_id` correlation column so a bot bridge
can persist agent commentary/tool output in the normal message stream
instead of ephemeral frames.
Activity rows ride the ordinary message.created fan-out and inherit
channel_seq for scrollback, but are excluded from full-text search
(sqlite FTS triggers gated on kind='message'; postgres partial GIN index
+ search query filter) and from unread/notification accounting (unread
subqueries and push notify path skip activity kinds).
Creating a message with kind != 'message' requires a bot token carrying
the new agent_activity:write scope. The scope is registered in both bot
stores' allowed-scope validation but deliberately EXCLUDED from the bot:*
bundles, so existing deployments gain no new capability. Human sessions
get 403, bots without the scope get 403, unknown kinds get 400. Targets
remain single-channel/single-DM via the existing create endpoints.
migrations: sqlite 0017, postgres 0011.
Tests: authz matrix (403 human, 403 missing scope, 400 unknown kind,
success for scoped bot, bundle-exclusion, membership privacy) and store
round-trip + unread exemption + search exclusion.
Render durable agent activity messages (kind agent_commentary / agent_tool) inline in the normal message flow with a theme-tied accent rail and a small commentary/tool badge so operators can tell intermediate output from the final answer. The accent comes from a new --activity-accent token (blue on light theme, amber on dark) driven by the existing prefers-color-scheme override, not hardcoded hex. Add a client-only "Show agent activity" preference (default on) in account settings that filters activity kinds from the rendered stream, persisted in localStorage like other client prefs. The client unread bump path also skips activity kinds to match server accounting.
Replace the flat one-row-per-activity rendering (a badged row per agent_commentary/agent_tool message) with a single coalesced preamble block per agent turn, matching the clickglass guidestone: - Consecutive same-turn activity rows (grouped by turn_id) collapse into one block: incrementing commentary prose plus a collapsed-by-default, expandable list of tool calls. Done entirely at render time; no schema or bridge change (rows already carry turn_id). - The block opens while the turn is live (pulsing LIVE pill) and collapses to a single expandable line once the turn ends (DONE). - Tool sub-items render as tool name + derived action + truncated arg detail (one row per call), not the full coalesced step chain. - Replace the single show-agent-activity toggle with two independent operator flags: hide-commentary and hide-tool-calls (localStorage, with migration from the legacy key). Both set omits the block. New: lib/chat/agent-activity.ts (coalescer), lib/chat/tool-detail.ts (glyph/action resolver), components/messages/PreambleBlock.svelte. Also embed dist with all: so underscore-prefixed SvelteKit chunks are not silently dropped from the binary.
Rework the coalesced agent preamble block to the clickglass display contract, per operator review: - Commentary prose and tool calls now interleave in arrival order (commentary, tool, commentary, tool) instead of prose-then-tool-list. - Each tool call is its own row, collapsed by default to one line (glyph + action + tool name + truncated arg detail) on a light blue wash; clicking expands it to the full stored body (step-chain head rendered as plain monospace, output as markdown). - Grouping is by turn_id across the whole list, not adjacency: the bridge's debounced trailing commentary can land after the final answer, which previously split the block and visually swallowed the final message. The final answer now always renders as a clearly separate ordinary message below the block. - The block collapses to a single line when the final answer lands (same-author ordinary message after the turn opened), when anything follows the turn's last activity row, or when the turn goes stale (3 min without frames) so orphaned turns don't pulse LIVE forever. - Preamble wash lightened; tool rows use new --tool-call-bg tokens (light blue on both themes). Both operator flags re-verified across all four states; hide-both omits blocks entirely. Embedded web dist rebuilt.
…collapsed) The pill previously read "done" for any finished turn regardless of whether the block was open. Now it mirrors what the toggle will do: "collapsed" when closed, "expanded" when open, "live" while streaming. Includes embedded dist rebuild.
Within an agent message group the synthetic preamble row is immediately followed by the same author final answer. Tag the paired rows (before-final-message / after-preamble) and draw them as a single bordered card with a flat internal seam, mirroring the ClawCanvas inline model so the activity log and the answer read as one unit. Scoped to the paired rows only; a standalone or still-live preamble keeps its full-bleed wash and gutter accent.
|
@clawsweeper this is the durable follow-up to #24 (now merged). It turns the ephemeral activity strip into a persistent, collapsible preamble block: coalesced commentary + per-tool rows (detail collapsed by default), visibility-state pill, and the preamble boxed together with the final answer into one cohesive card, plus a profile toggle. Green gate on post-merge |
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds support for durable “agent activity” messages (commentary + tool calls) that are persisted server-side, authorized via a dedicated bot scope, and rendered client-side as a coalesced “preamble block” UI.
Changes:
- Introduces
kind+turn_idmessage fields, DB migrations (SQLite + Postgres), and scope-gated HTTP create paths for agent activity rows. - Adds a client-side coalescing pipeline to collapse per-turn agent activity rows into a single preamble block with per-tool expand/collapse UI.
- Updates styling/theme variables and fixes Go
embedbehavior to include underscore/dot-prefixed asset files.
Reviewed changes
Copilot reviewed 31 out of 48 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| apps/web/src/styles/messages.css | Adds styling for the new preamble block and tool-call subrows. |
| apps/web/src/styles/base.css | Introduces theme variables for activity accents and tool-call row backgrounds. |
| apps/web/src/lib/types.ts | Extends message/event types to include kind, turn_id, and client-only preamble_block. |
| apps/web/src/lib/chat/tool-detail.ts | Adds tool name → glyph/action/detail mapping for preamble tool summaries. |
| apps/web/src/lib/chat/agent-activity.ts | Implements coalescing of agent activity rows into a single synthetic preamble message per turn. |
| apps/web/src/components/profile/ProfileSettingsModal.svelte | Adds user toggles to hide commentary and/or tool calls. |
| apps/web/src/components/messages/PreambleBlock.svelte | New component rendering the collapsible preamble + per-tool expansion. |
| apps/web/src/components/messages/MessageRow.svelte | Renders PreambleBlock when preamble_block is present and adds pairing classes. |
| apps/web/src/components/messages/MessageGroup.svelte | Passes adjacent messages into MessageRow for preamble/answer pairing logic. |
| apps/web/src/ChatApp.svelte | Applies coalescing to the message stream and persists hide flags in localStorage. |
| apps/api/internal/webassets/webassets.go | Switches to //go:embed all:dist to include underscore/dot-prefixed asset files. |
| apps/api/internal/webassets/dist/index.html | Updates built web asset references for the new build output. |
| apps/api/internal/webassets/dist/200.html | Updates built web asset references for the new build output. |
| apps/api/internal/webassets/dist/_app/immutable/nodes/5.kueAycLe.js | Build artifact updated for new chunk references. |
| apps/api/internal/webassets/dist/_app/immutable/nodes/4.Z19KiFas.js | Build artifact updated for new chunk references. |
| apps/api/internal/webassets/dist/_app/immutable/nodes/3.C3tghYkn.js | Removes old hashed build artifact. |
| apps/api/internal/webassets/dist/_app/immutable/nodes/3.B-d9fl0n.js | Adds new hashed build artifact. |
| apps/api/internal/webassets/dist/_app/immutable/nodes/2.BQWrx22g.js | Build artifact updated for new chunk references. |
| apps/api/internal/webassets/dist/_app/immutable/entry/app.Xpli9ePf.js | Removes old hashed build artifact (entry). |
| apps/api/internal/webassets/dist/_app/immutable/assets/0.DeZxIIxo.css | Removes old hashed build artifact (CSS). |
| apps/api/internal/store/types.go | Adds message kind constants, normalization/validation, and new agent_activity:write scope. |
| apps/api/internal/store/sqlite/sqlite.go | Stores normalized kind + turn_id and includes them in realtime event payloads. |
| apps/api/internal/store/sqlite/sqlc/schema.sql | Adds kind and turn_id columns to SQLite messages schema. |
| apps/api/internal/store/sqlite/sqlc/queries.sql | Excludes activity kinds from unread counts and inserts kind/turn_id on message writes. |
| apps/api/internal/store/sqlite/migrations/0017_agent_activity.sql | SQLite migration adding columns, index, and FTS trigger filtering for kind=message. |
| apps/api/internal/store/sqlite/helpers.go | Includes kind and turn_id in message selects/scans. |
| apps/api/internal/store/sqlite/dms.go | Stores normalized kind + turn_id for direct messages and includes them in events. |
| apps/api/internal/store/sqlite/bots.go | Makes new scope grantable but not inherited by bot bundles. |
| apps/api/internal/store/sqlite/agent_activity_test.go | Adds SQLite tests for kind/turn_id round-trip, unread exemption, and search exclusion. |
| apps/api/internal/store/postgres/sqlc/schema.sql | Adds kind/turn_id columns and makes search index partial on kind=message. |
| apps/api/internal/store/postgres/sqlc/queries.sql | Excludes activity kinds from unread counts and inserts kind/turn_id on message writes. |
| apps/api/internal/store/postgres/search.go | Filters search results to kind=message. |
| apps/api/internal/store/postgres/postgres.go | Stores normalized kind + turn_id and includes them in realtime event payloads. |
| apps/api/internal/store/postgres/migrations/0011_agent_activity.sql | Postgres migration adding columns/index and rebuilding partial search index. |
| apps/api/internal/store/postgres/helpers.go | Includes kind and turn_id in message selects/scans. |
| apps/api/internal/store/postgres/dms.go | Stores normalized kind + turn_id for direct messages and includes them in events. |
| apps/api/internal/store/postgres/bots.go | Makes new scope grantable but not inherited by bot bundles. |
| apps/api/internal/httpapi/server.go | Adds kind/turn_id to create-message requests and enforces activity scope for activity kinds. |
| apps/api/internal/httpapi/features.go | Adds kind/turn_id to create-DM requests and enforces activity scope for activity kinds. |
| apps/api/internal/httpapi/agent_activity_test.go | Adds API-level tests for authz, privacy, and non-inheritance of the new scope. |
Files not reviewed (4)
- apps/api/internal/store/postgres/storedb/models.go: Generated file
- apps/api/internal/store/postgres/storedb/queries.sql.go: Generated file
- apps/api/internal/store/sqlite/storedb/models.go: Generated file
- apps/api/internal/store/sqlite/storedb/queries.sql.go: Generated file
Comments suppressed due to low confidence (4)
apps/web/src/components/messages/PreambleBlock.svelte:1
class:openis missing its boolean expression, which is inconsistent with the rest of the file and will fail to compile in Svelte versions that requireclass:name={expr}. Bind it to theopenboolean (e.g.,class:open={open}) so the chevron rotates only when expanded.
apps/web/src/components/messages/MessageRow.svelte:1- The
is-preamble-collapsedclass name implies “currently collapsed”, but it is being driven byblock.final. Final blocks can be manually expanded inPreambleBlock, so this CSS class will be applied even when the preamble is open, causing “collapsed” spacing/styling to be incorrectly applied. Consider moving the collapsed/expanded styling decisions intoPreambleBlock.svelte(wherepreambleOpenis known) by toggling a class there, or by exposing/binding the open state to the parent soMessageRowcan reflect the actual UI state.
apps/web/src/lib/chat/agent-activity.ts:1 - Finality detection can become O(T * N) due to scanning from
firstIndex+1to the end for every turn. On long timelines with many turns, this can noticeably impact scroll/render performance. A more efficient approach is a single reverse pass to precompute “is there a later same-author ordinary message after index i” (e.g., trackseenOrdinaryByAuthorwhile iterating from the end), or compute per-index “next ordinary message author” so each turn checks finality in O(1).
apps/web/src/lib/types.ts:1 EventPayload.kindis typed as a plainstring, even thoughMessage.kindis already a constrained union ("message" | "agent_commentary" | "agent_tool"). TypingEventPayload.kindto reuse the same union (orMessage["kind"]) would prevent typos and keep client logic likehandleUnreadBumpaligned with server-supported values.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The preamble now reads clearly yellow while the turn streams (growing bubble), and collapses to a distinct yellow bar capping the blue final answer block. Retunes --activity-accent to yellow on both themes (#d49415 light, #e8b23e dark), strengthens the streaming wash to 17%, and gives the collapsed boxed-pair bar a 22% wash + warm bottom divider so it stands apart from the blue delivery. Rebuilds embedded web dist.
…wcanvas)
Per-channel runtime snapshot (model, thinking, context used/limit) served from
clickclack's own SQLite/Postgres store, fed by the agent-bridge reading the
gateway directly. No /api/live proxy, no ClawCanvas dependency.
- migration: channel_runtime table (sqlite 0018 + postgres 0012)
- store: GetChannelRuntime / UpsertChannelRuntime / SetChannelRuntimeOverride
with disjoint bridge-snapshot vs picker-override columns (neither clobbers
the other), raw SQL in both backends
- api: GET (session read) / PUT (bot agent_activity:write stamp) / PATCH
(session override) /api/channels/{id}/runtime
- test: snapshot/override separation + empty-record-on-unknown-channel
- docs: RUNTIME_CONTROLS_INDEPENDENT_PLANE.md (data plane + per-field sourcing)
Increment 1 of 4. Next: bridge stamp, frontend port (picker+meter), deploy.
…point
Increment 2/3 of the composer runtime controls: port the model picker and
context meter into the composer utility bar, fed by clickclack's own
/api/channels/{id}/runtime endpoint (the increment-1 data plane), with no
ClawCanvas dependency.
- channel-runtime.ts: runtime resolver + provider/model catalogs (frontend only)
- channel-runtime-store.svelte.ts: narrow runtime store; loadChannelRuntime()
reads the native endpoint, persistRuntimeOverride() PATCHes picker picks,
seedDemoChannelRuntime() keeps the chrome populated until the bridge stamp lands
- ComposerModelPicker / ModelPickerMenu / ComposerContextMeter: ported components
- ChatComposer: composer-utility row hosts the meter + picker (showModelPicker)
- ChatApp: seed on boot, load on channel switch, override handlers persist via PATCH
- rebuilt embedded web dist
Demoed live on the :8100 stock instance against a real runtime row; meter/picker
update end-to-end when the DB row changes. PR intentionally not opened yet.
Bundle the remaining composer/runtime-controls UI work onto the PR: - User (self) messages render in their own accent-tinted bubble that mirrors the agent delivery bubble's weight (7x11 padding, 11px radius, 3px rail) using a single theme-tied token (--user-bubble-soft = color-mix of --accent), so the dark theme override flows automatically. Bubble hugs content (fit-content, capped) so alignment is visible. - Configurable self-message alignment (left default / right) via a new "Your message alignment" select in Account settings. Persisted in localStorage (clickclack:user-align:v1) and applied as a data-user-align root attribute; messages.css mirrors the self group (avatar gutter, header, bubble) without prop drilling. - MessageGroup gains currentUserID -> is-self; bots never match the current user id, so an agent group is never marked self even in a DM. - AgentResponding: compact "agent is responding" status above the composer, driven by any unfinalized tracked turn line. - Context-meter detail popover + model-picker refinements. - Drop the dead "Preamble" title (markup + .preamble-title CSS); the preamble header now reads chevron + state pill + action only.
…ontrols # Conflicts: # apps/api/internal/webassets/dist/200.html # apps/api/internal/webassets/dist/_app/immutable/entry/app.Dphjwod_.js # apps/api/internal/webassets/dist/_app/immutable/entry/app.Xpli9ePf.js # apps/api/internal/webassets/dist/_app/immutable/entry/app.inV1wtz9.js # apps/api/internal/webassets/dist/_app/immutable/nodes/2.BQWrx22g.js # apps/api/internal/webassets/dist/_app/immutable/nodes/2.C6_wREb2.js # apps/api/internal/webassets/dist/_app/immutable/nodes/2.DF-TCSVN.js # apps/api/internal/webassets/dist/_app/immutable/nodes/4.CsfpOieR.js # apps/api/internal/webassets/dist/_app/immutable/nodes/5.BR4pOwJp.js # apps/api/internal/webassets/dist/_app/immutable/nodes/5.ClAa746V.js # apps/api/internal/webassets/dist/_app/immutable/nodes/5.kueAycLe.js # apps/api/internal/webassets/dist/index.html # apps/web/src/ChatApp.svelte # apps/web/src/components/profile/ProfileSettingsModal.svelte
On a ~360px viewport the three full-width runtime widgets (markdown toolbar, context meter, model picker) shared one fixed 1fr/auto/1fr grid and overlapped into an unreadable jumble. Apply the learned mobile treatment below 520px: - composer-runtime-controls drops to two rows: context meter + model picker on top (space-between), markdown glyph strip beneath. - ContextMeter pill collapses to the fill bar + percentage + caret; token counts and cache-hit detail move to the one-tap inspector. - ModelPicker pill collapses to dot + model name (truncated) + caret; provider prefix and thinking/speed labels live in the picker menu. - Right-adjusted self bubbles use the 44px mobile avatar gutter instead of the desktop 56px column for list consistency. Rebuilt embedded web dist.
|
Live demo is up in the PR description. Recaptured against the stock validation instance so the chat actually moves: the composer types and sends, the agent works live in-channel (commentary + per-tool rows stream in), the trail folds into the real COLLAPSED gold preamble pill, then expand → collapse, plus the model picker / context meter and a mobile pass. No caption overlays, no frozen frames. Full-quality MP4 (1600×1000 · 30 fps · 83 s): https://github.com/ragesaq/clickclack/raw/media/composer-demo-video/.proof/video/clickclack-composer-demo.mp4 |
|
Codex review: needs maintainer review before merge. Reviewed June 23, 2026, 7:53 AM ET / 11:53 UTC. Summary Reproducibility: not applicable. as a bug reproduction: this is a feature PR. The maintainer-review concern is source-verifiable through the runtime PATCH endpoint, generated sqlc merge ref, and accompanying tests. Review metrics: 3 noteworthy metrics.
Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Mantis proof suggestion Risk before merge
Maintainer options:
Next step before merge
Security Review detailsBest possible solution: Merge after maintainers explicitly accept the channel-scoped runtime override policy and are satisfied with the inspected merge-ref storage/API result; otherwise narrow PATCH authorization or make the override behavior opt-in before landing. Do we have a high-confidence way to reproduce the issue? Not applicable as a bug reproduction: this is a feature PR. The maintainer-review concern is source-verifiable through the runtime PATCH endpoint, generated sqlc merge ref, and accompanying tests. Is this the best way to solve the issue? Unclear as a product choice: the implementation is technically coherent and tested, but the channel-writer model/reasoning override policy needs maintainer acceptance before it is clearly the best permanent contract. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 9d29bc6356f2. Label changesLabel justifications:
Evidence reviewedWhat I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
|
…ime handlers Reject a non-empty turn_id on an ordinary 'message' row (400, ErrTurnIDNotAllowed) at the shared resolveMessageKind choke point used by both the channel and direct-message create paths. turn_id correlates agent activity rows only; an ordinary message carrying one violated the documented 'Empty for ordinary messages' contract (Copilot review, server.go:655). Activity kinds (agent_commentary/agent_tool) may still carry a turn_id. Add runtime_test.go covering the previously 0%-covered composer runtime handlers (get/put/patchChannelRuntime) plus the turn_id contract on both create paths. Lifts go coverage 84.3% -> 86.5%, clearing the 85% gate.
|
Pushed 1. 2. Go coverage gate (the Proof (local,
Diff: |
Address Clarity review on PR openclaw#27: - turn_id field doc now states both halves of the contract: must be empty for ordinary messages (kind=message) with a 400 ErrTurnIDNotAllowed, optional for agent activity kinds. - TestMessageTurnIDContract now asserts the 400 response body carries the ErrTurnIDNotAllowed sentinel text (channel + DM paths), proving the failure reason reaches the client rather than an opaque 400.
The composer-runtime-controls work wraps the composer in a .composer-dock that reserves fixed internal chrome above the input card: an always-present agent-responding band plus the runtime-controls row (model picker + context meter). That chrome is intentional dead space, not distance between the newest message and the composer area. expectMessageNearComposer measured the gap to the inner .composer-card, so the new ~28px chrome pushed it past the 24px budget and failed three auto-scroll assertions (unread-divider jump, live-channel pin, escape-to- latest). Re-anchor the measurement to .composer-dock (the composer area boundary) so the assertion stays about "is the newest message pinned to the composer?" rather than tracking composer-internal chrome height, and keep the same 24px slack budget. Falls back to .composer-card when no dock is present. Full Playwright suite: 37 passed.
The history-paging test scrolled to the bottom of a search-jump window and asserted exactly one newer page fetched (toHaveLength(1)). The page count is not an invariant: it depends on how many newer rows fit above the prefetch sentinel before it leaves the trigger margin. A single 50-row page sits right at one viewport, so the agent-activity/composer layout changes on this branch pushed it to the edge where scroll-to-bottom occasionally needs a second sequential page (after_seq=100 then after_seq=150) to fill. That is benign forward fill, confirmed by the sequential cursors, not a runaway to the live tail. This made the test ~37% flaky on this branch (3/8 local repeats) while it is deterministic on main, because the layout changes here moved the fill to the boundary. Assert the real invariant instead: newer-paging engaged (length >= 1) and stayed incremental (history-msg-149 visible, live-tail history-msg-259 still absent). The 259-absence check is the runaway guard. Verified: test 1087 12/12 green after the change (was 3/8); full Playwright suite 37 passed.
Closes the two open HyperReview gates on the runtime cleanup and the stale-carryover bug the new test caught. - web: ComposerContextMeter renders the pill only when the channel reports context-window accounting (context_used/limit). Empty and override-only channels show no meter instead of a meaningless "?/?" placeholder. This also tears the pill down on an in-app channel switch, so the previous channel's meter cannot linger after the picker has already cleared. - api: patchChannelRuntime validates the picker override at the boundary: thinking must be off|minimal|low|medium|high|xhigh|adaptive (empty clears), model is length-bounded (<=256, no control chars). A channel member can no longer persist an arbitrary/oversized string the bridge would apply to a gateway session. Bridge PUT is unchanged (bot + agent_activity:write). - test(e2e): add an in-app sidebar-click channel switch (not page.goto) that asserts the override channel's model chrome and context meter both clear when switching to an empty channel. - test(api): cover cookie+CSRF PATCH (403 without header, 200 with) and the new override validation rejections. - docs: document the meter render-gate and the PATCH validation contract.
|
Pushed
On top of the review: API-boundary validation on the PATCH ( Proof (network/API behavior):
PR body updated with the full breakdown and the policy note that channel-scoped override ( @clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
Maintainer closeout for
Thanks @ragesaq for the original design and implementation. |

Summary
This keeps the strongest bounded part of the contributor proposal. The unrelated model/reasoning runtime-control plane was removed: ClickClack transports and renders agent activity, while LLM runtime selection remains owned by the external agent runtime.
Security and invariants
agent_activity:writebot-token scope; deliberately excluded from default bot scope bundles.Verification
pnpm checkgo test ./apps/api/internal/store/sqlite ./apps/api/internal/store/postgres ./apps/api/internal/httpapiCI=1 pnpm exec playwright test --reporter=line --workers=1— 41/41 passedThanks @ragesaq for the original agent-activity design and implementation.