Records reasoning start and end times on persisted reasoning
`ChatMessagePart`s so reasoning duration can be computed for stored
chats. Backend-only: no SSE changes and no frontend rendering ship in
this PR.
The `created_at` field on `ChatMessagePart` is extended to also be
present on `reasoning` parts (it previously appeared only on `tool-call`
and `tool-result`), and a new `completed_at` field is added for
`reasoning` parts.
### How timestamps are recorded
- `StreamPartTypeReasoningStart`: stamp `startedAt = dbtime.Now()` on
the active reasoning state.
- `StreamPartTypeReasoningEnd`: stamp `completedAt = dbtime.Now()` and
append both into parallel `[]time.Time` slices on `stepResult`.
- Persistence reads the slices in occurrence order (reasoning has no
provider-side ID) and applies them to the matching `ChatMessagePart` via
`buildAssistantPartsForPersist`. The first reasoning block's stamps go
onto the first reasoning part, and so on.
- `flushActiveState` flushes partial reasoning interrupted before
`StreamPartTypeReasoningEnd` with `startedAt` from the active state and
`completedAt = dbtime.Now()` at the interruption.
### Why two fields, not one?
Tool calls and results are point events. The frontend computes their
duration by subtracting the call's `created_at` from the result's
`created_at`. Reasoning is one assistant part that brackets a span, so
we record both endpoints on the part itself.
### Why not stamp in `PartFromContent`?
Same rationale as #24101: `PartFromContent` is called during both SSE
publishing and persistence. Stamping there would yield incorrect
persistence-time timestamps for reasoning blocks that finished much
earlier in the step. Instead we capture in the chatloop and apply during
persistence.
<details><summary>Implementation plan</summary>
- `codersdk/chats.go`: extend `CreatedAt`'s `variants` to include
`reasoning?`; add `CompletedAt *time.Time` with `variants:"reasoning?"`.
- `coderd/x/chatd/chatloop/chatloop.go`: extend `reasoningState` with
`startedAt`; extend `stepResult` and `PersistedStep` with parallel
`[]time.Time` reasoning slices; stamp on
`ReasoningStart`/`ReasoningEnd`; thread the slices through all
`PersistStep` call sites including the interrupt-safe path; record
partial reasoning in `flushActiveState`.
- `coderd/x/chatd/attachments.go`: walk reasoning parts in occurrence
order and apply `step.ReasoningStartedAt[i]` to `part.CreatedAt` and
`step.ReasoningCompletedAt[i]` to `part.CompletedAt`.
### Tests
- `codersdk/chats_test.go` round-trips `created_at` + `completed_at` on
reasoning parts and verifies omission when absent and partial
interrupted parts.
- `coderd/x/chatd/chatprompt/chatprompt_test.go` asserts
`PartFromContent(ReasoningContent{})` does NOT stamp timestamps.
- `coderd/x/chatd/chatloop/chatloop_test.go`
`TestRun_ReasoningTimestamps` drives a stream with two reasoning blocks
and verifies parallel slices, monotonicity, ordering, non-zero values,
and content-block ordering.
`TestRun_InterruptedReasoningFlushesTimestamps` cancels mid-reasoning
and verifies `flushActiveState` records a non-zero pair.
- `coderd/x/chatd/attachments_test.go` covers
`buildAssistantPartsForPersist` for normal interleaved reasoning,
partial (zero `completed_at`), and missing slices.
</details>
> Generated by Coder Agents.
Co-authored-by: Coder Agent <agent@coder.com>
The `SectionHeadersCollapse` story (and every other
Chromatic-snapshotted story rendering the agents sidebar) was flaky
because each chat row renders `shortRelativeTime(chat.updated_at)` —
`"now"`, `"5m"`, `"7h"`, `"1d"`, etc. The values are computed against
the live wall clock from fixed fixture timestamps, so the rendered text
drifts on every run (e.g. `"7h"` → `"now"` mid-day) and Chromatic diffs
the change.
This wraps the timestamp in `<span data-chromatic="ignore">`, the
dominant codebase convention for time-varying text.
`data-chromatic="ignore"` appears 35 times across 20 files in
`site/src`, including `utils/schedule.tsx:139,153`,
`components/LastSeen/LastSeen.tsx:42`,
`modules/provisioners/Provisioner.tsx:80`,
`pages/OrganizationSettingsPage/.../ProvisionerRow.tsx:119`,
`JobRow.tsx:139`, `ProvisionerKeyRow.tsx:81`,
`pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx`
(multiple rows), `pages/HealthPage/WorkspaceProxyPage.tsx:144`, and
`pages/UserSettingsPage/TokensPage/TokensPageView.tsx:117`. By
comparison, only one component (`AppStatuses`) plumbs a reference date
through props, and there is no `Date.now`-stubbing precedent that would
actually stabilize `dayjs()`-based output like `shortRelativeTime`.
The wrap is scoped tightly: only the text node returned by
`shortRelativeTime` is ignored, so the sibling unread-indicator dot and
surrounding layout still participate in snapshots.
Filenames from the OS (e.g. `Screen Shot 2025-01-01 at 10.00.00 AM.png`
or `My Report (final).pdf`) flow unchanged through the chat-attach hooks
into chip labels, the persisted-attachment localStorage records, the
upload `Content-Disposition` header, and downstream LLM prompts.
Characters such as parentheses, brackets, quotes, shell or URL or path
metacharacters, whitespace, and control codes are all valid in HTTP
transport today but tend to break things further down the line (LLM tool
calls that quote the name, audit logs, any future S3/path interpolation,
shell-quoted tooling).
Sanitize at the boundary in `useFileAttachments.handleAttach` and
`useChatDraftAttachments.handleAttach` by mapping each incoming `File`
through a new `renameChatFileForUpload` helper. The helper replaces
`()[]{}<>'\"\`;,:*?|&#$\\/`, whitespace, and control characters with
`_`, collapses adjacent underscores, trims leading or trailing `_`, `.`,
or whitespace, and falls back to `"file"` if the result is empty. ASCII
alphanumerics, `.`, `-`, `_`, and all other Unicode letters and symbols
(CJK, emoji, accented Latin) are preserved so localized names remain
readable. Already-safe names return the same `File` reference; the chat
UI keys preview-URL, upload-state, and text-content Maps on the `File`
object, so identity must be stable.
The server's existing `chatfiles.NormalizeStoredFileName` (control-char
strip plus 255-byte truncate) is untouched. This is a client-only
hardening pass.
Add prev/next chevron buttons to the action row under each user message
in the agent chat transcript. Clicking jumps the scroll container to the
neighbouring user prompt's sticky sentinel (smooth scroll, no composer
mutation). Arrows disable rather than wrap when at the ends.
## Why
When a chat gets long, scrolling back to a previous prompt to see the
question that produced an answer is annoying. The transcript already has
a stable per-prompt anchor (`data-user-sentinel`) used by the
sticky-message logic, so reusing it for navigation is cheap and
consistent with the existing scroll model.
## Implementation
- `ChatMessageItem` accepts three optional props (`prevUserMessageId`,
`nextUserMessageId`, `onJumpToUserMessage`) and renders the two chevron
buttons inside the existing `message-actions` row when the message is a
user role.
- `StickyUserMessage` forwards the props to both copies of
`ChatMessageItem` (flow + sticky overlay).
- `ConversationTimeline` derives the ordered list of visible user
message IDs using the same `deriveMessageDisplayState` predicate that
controls visibility, builds a neighbour map, and supplies the jump
handler. The handler resolves the target via
`[data-user-sentinel][data-user-message-id="..."]` and smooth-scrolls
the closest `.overflow-y-auto` ancestor by the sentinel's offset
(mirroring the existing edit-flow scroll helper).
- New `data-user-message-id` attribute on the sentinel `div` to make the
lookup direct.
- New Storybook story `UserMessageJumpArrows` covers: arrow counts,
disabled-at-ends, and that clicking Next scrolls the next user sentinel
to the top of the scroller. JSDOM doesn't animate smooth scroll, so the
play function monkey-patches `scrollBy` to apply the requested top
offset synchronously.
No API, DB, or audit-table changes. Frontend only.
## Test
- `pnpm test:storybook
src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx`
— 46 passed (incl. new story).
- `pnpm test:storybook
src/pages/AgentsPage/components/ChatConversation/` — 67 passed.
- `pnpm lint:types`, `pnpm lint:fix`, `pnpm lint:compiler`, `pnpm
format:check` — all clean.
- Local `make pre-commit` ran via the pre-commit hook on commit.
<details>
<summary>Implementation plan</summary>
Plan lives at
`/home/coder/.coder/plans/PLAN-41b442d8-05bc-4b62-b1ba-155a7cef09bc.md`
in the agent workspace. Summary:
1. Add three optional props (`prevUserMessageId`, `nextUserMessageId`,
`onJumpToUserMessage`) to `ChatMessageItem` and render
`ChevronLeft`/`ChevronRight` buttons inside the existing actions row
when the message is a user role. Disable each button when its neighbour
is undefined.
2. Forward those props through `StickyUserMessage` to both
`ChatMessageItem` instances (flow + sticky overlay).
3. In `ConversationTimeline`, build the ordered list of visible user IDs
using the same `deriveMessageDisplayState` predicate, derive a neighbour
map, and implement `handleJumpToUserMessage` that looks up
`[data-user-sentinel][data-user-message-id="${id}"]`, finds the closest
`.overflow-y-auto` ancestor, and smooth-scrolls by the sentinel's
offset.
4. Add `data-user-message-id` to the sentinel so the lookup is direct.
5. Cover the behaviour with a `UserMessageJumpArrows` Storybook play
function.
</details>
---
*This PR was authored by Coder Agents on behalf of @ibetitsmike.*
## Summary
- Wire the Appearance settings page to the new theme mode dropdown and
sync or single theme selectors.
- Update Appearance page tests and stories for theme mode behavior.
- Update the user settings e2e test to exercise single theme selection.
## Dependencies
- Depends on #25076, #25180, #25181, and #25182.
- This PR targets helper branch `pr25077/05-theme-mode-dropdown-base`,
which contains dependency commits only, so this PR diff stays focused on
final dropdown wiring. Rebase and retarget after the dependency PRs
merge.
## Validation
- `pnpm -C site exec vitest run --project=unit
src/pages/UserSettingsPage/AppearancePage/AppearancePage.test.tsx
src/theme/themeMode.test.ts src/api/queries/users.test.ts`
- `pnpm -C site lint:types`
- `pnpm -C site storybook:ci`
- `pnpm -C site build`
- `pnpm -C site playwright:test -- e2e/tests/users/userSettings.spec.ts`
- Pre-commit hook passed on the branch commit.
## Summary
- Add appearance mutation context so optimistic cache updates can be
rolled back on failure.
- Restore previous appearance settings, or remove the optimistic cache
entry when no previous data exists.
- Merge successful server responses back into the appearance cache while
preserving request fields when responses are partial.
## Dependencies
- Stacked on #25076 because the tests and generated types use the new
appearance theme mode fields.
## Validation
- `pnpm -C site exec vitest run --project=unit
src/api/queries/users.test.ts`
- `pnpm -C site lint:types`
- Pre-commit hook passed on the branch commit.
Closes
https://linear.app/codercom/issue/AIGOV-284/add-group-budgets-table-and-crud-api
## Summary
Adds the `group_ai_budgets` table and the following endpoints:
- `GET /api/v2/groups/{group}/ai/budget`
- `PUT /api/v2/groups/{group}/ai/budget`
- `DELETE /api/v2/groups/{group}/ai/budget`
Each group may have at most one budget row. If no row exists, no budget
is enforced.
### Feature gate
Added `RequireFeatureMW(FeatureAIBridge)` on the `/ai/budget` sub-route.
## RBAC
Authorization reuses `rbac.ResourceGroup` with the existing
`.InOrganization(...).WithID(...)` scoping model.
The `dbauthz` wrappers load the parent `groups` row and authorize
against it.
No new resource type is introduced. As a result, anyone with
`group:update` permissions (Owner, OrgAdmin, or UserAdmin within the
organization) can manage AI budgets for that group.
## Read access for group members
`database.Group.RBACObject()` grants `policy.ActionRead` to all members
of the group through the group ACL:
```go
func (g Group) RBACObject() rbac.Object {
return rbac.ResourceGroup.WithID(g.ID).
InOrg(g.OrganizationID).
// Group members can read the group.
WithGroupACL(map[string][]policy.Action{
g.ID.String(): {
policy.ActionRead,
},
})
}
```
Because the `GET` endpoint authorizes against the same loaded `Group`
object, any group member can call:
```text
GET /api/v2/groups/{group}/ai/budget
```
`PUT` and `DELETE` remain admin-only. The group ACL grants only
`ActionRead`, so write operations continue to require role-based
`group:update` permissions.
## Alternative considered
A dedicated `rbac.ResourceGroupAiBudget` resource would allow budget
management to be separated from general group administration.
We decided not to add that complexity for now.
Move the "Read the docs" button from the Audit Logging subsection up to
the primary Observability header's `actions` prop, matching the layout
pattern used by General, Network, and other deployment settings pages.
Also updates the docs URL from `/admin/security/audit-logs` to
`/admin/monitoring` to reflect the page-level scope.
> Generated by Coder Agents on behalf of @designertyler
---------
Co-authored-by: TJ <tracy@coder.com>
Previously, `hasAITaskResources()` scanned the Terraform graph for
`coder_ai_task` node labels. The graph includes resource definitions
regardless of `count`, so templates with `count = 0` were incorrectly
marked as `HasAITasks = true`, causing them to appear on the `/tasks`
page when no AI task resources would be created.
Replace the graph-based check with `len(aiTasks) > 0`. The `aiTasks`
slice is populated from state modules where Terraform has already
evaluated `count`, so it correctly reflects actual resource instances.
ref:
https://linear.app/codercom/issue/ECO-39/make-coder-tasks-respect-count
> Generated with [Coder Agents](https://coder.com/agents)
---------
Signed-off-by: 35C4n0r <work.jaykumar@gmail.com>
Signed-off-by: Jay Kumar <jay.kumar@coder.com>
## Summary
- Add `legacyAutoToSync` to map legacy auto-family theme preferences to
light and dark sync pairs.
- Expand colorblind theme tests for legacy auto-family handling.
- Move tritan success and git-added surfaces onto the sky-blue palette
in theme roles and CSS variables.
## Dependencies
- None. This can merge independently before the dropdown work.
## Validation
- `pnpm -C site exec vitest run --project=unit
src/theme/colorblind.test.ts`
- `pnpm -C site lint:types`
- Pre-commit hook passed on the branch commit.
## Summary
- Update AgentsPage diff viewer CSS so changed lines use theme-aware git
added and deleted surfaces.
- Add unit coverage for the changed-line CSS variables and line-type
selectors.
## Dependencies
- None. This is independent of the theme mode API and dropdown stack.
## Validation
- `pnpm -C site exec vitest run --project=unit
src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts`
- Pre-commit hook passed on the branch commit.
Follow-up to #25004. The merged change cycles only through messages
already loaded in the in-memory chat store (page size 50). Long chats
and chats whose oldest turns have rolled out of the page lose access to
their earlier prompts in the composer's up/down arrow cycle. This PR
adds a dedicated server endpoint that returns the full prompt history,
newest first, and rewires the composer to use it.
## What changed
### Endpoint
`GET /api/experimental/chats/{chat}/prompts?limit=N`
```go
type ChatPrompt struct { ID int64; Text string }
type ChatPromptsResponse struct { Prompts []ChatPrompt }
```
- `limit`: `0..2000`. `0` (the default) is treated as the server-side
default of 500; out-of-range values return `400`. Negative values are
rejected by the SDK's `PositiveInt32` parser before reaching the
handler.
- Auth: parent-chat read in `dbauthz`, mirroring
`GetChatMessagesByChatID`.
- The SQL filters `role='user'`, `deleted=false`, `visibility IN
('user','both')`, guards the lateral with `jsonb_typeof(content) =
'array'` so legacy V0 scalar-string rows are silently skipped, then
unrolls `content` JSONB with `WITH ORDINALITY` and concatenates only
`type='text'` parts in original order via `string_agg(... ORDER BY
ordinality)`. Messages whose joined text is whitespace-only are dropped
via `HAVING ... ~ '\S'` so cycling never lands on a blank entry.
### Partial index (migration `000494`)
```sql
CREATE INDEX idx_chat_messages_user_prompts
ON chat_messages (chat_id, id DESC)
WHERE deleted = false
AND role = 'user'
AND visibility IN ('user', 'both');
```
The partial WHERE matches the query's filter exactly and the key order
matches `ORDER BY id DESC`, so the planner gets both the filter and the
ordering from the index without a sort step.
`EXPLAIN ANALYZE` on a synthetic 51-chat × 5,000-message dataset (≈260k
rows, 10k user prompts in the target chat, `random_page_cost=1.1`):
| | Plan | Buffers hit | Time |
|---|---|---|---|
| Without index | `Index Scan Backward using chat_messages_pkey`,
**250,848 rows removed by filter** | 6,683 | 32.4 ms |
| With index | `Index Scan using idx_chat_messages_user_prompts`, no
filter | 38 | 1.3 ms |
≈25× faster, 175× fewer buffer hits.
### Frontend
- `chatPromptsKey` / `chatPromptsQuery` factories in
`site/src/api/queries/chats.ts` (`staleTime: 30s`, `enabled: chatId !==
""`, asks the server for 500 prompts).
- `ChatPageContent.tsx` replaces the in-memory derivation with
`useQuery(chatPromptsQuery(chatId ?? ""))`. The composer's existing
`cycleHistorySnapshotRef` anchors the in-flight cycle so a refetch
arriving mid-cycle cannot shift the indexed prompt out from under the
user.
- `getEditableUserMessagePayload` now concatenates user-message text
parts verbatim, mirroring the server's `string_agg(part->>'text', ''
ORDER BY ordinality)`, instead of routing through the streaming-oriented
`parseMessageContent` / `appendText` pipeline (which drops
whitespace-only chunks — correct for assistant streams, wrong for a
user's persisted message). This keeps the cycle and the edit path in
agreement on the same message. File blocks are still pulled separately
via
`parseMessageContent(...).blocks.filter(isEditableUserMessageFileBlock)`.
- Cache invalidation in `createChatMessage.onSuccess`,
`editChatMessage.onSettled`, and `useChatStore.upsertCacheMessages`
(only when an upserted message has `role === "user"`).
- Page-level stories pre-seed `chatPromptsKey(CHAT_ID)` from the same
`messagesData` to keep them offline.
## Tests
- New `TestGetChatUserPrompts` in `coderd/exp_chats_test.go` with five
subtests:
- `NewestFirstFiltering` — multi-part concatenation, non-text parts
skipped, whitespace-only filtered, soft-deleted excluded, `model`-only
visibility excluded, assistant-role excluded by `cm.role = 'user'`,
legacy V0 scalar row silently excluded by the `jsonb_typeof` guard,
ordering newest first.
- `LimitClampsResults` — explicit `limit=2` returns the two newest
prompts.
- `InvalidLimitRejected` — `limit=5000` is `400 Bad Request`.
- `NotFoundForOtherUsers` — a separate user in the same org gets `404`,
not the prompts.
- `EmptyResultIsJSONArray` — zero-message chat and assistant-only chat
both return `Prompts: []` (non-nil, empty).
- New unit test in `messageParsing.test.ts` asserting that
`getEditableUserMessagePayload(["hello", " ", "world"])` returns `"hello
world"`, locking in the agreement with the SQL `string_agg`.
- `dbauthz_test.go` adds the
`MethodTestSuite.TestChats/GetChatUserPromptsByChatID` entry, asserting
parent-chat `policy.ActionRead`.
- `pnpm test src/pages/AgentsPage` — 1159 passed, 2 skipped.
- `make gen` produces no diff.
## Manual verification
Seeded a dev chat with Claude Sonnet 4.6 via the aibridge Anthropic
provider and posted 20 user prompts end-to-end. Verified that the
`/prompts` endpoint returns 20 rows newest-first, that `limit=10` clamps
correctly, that `limit=0` uses the server default of 500, and that the
up/down keyboard cycle in the composer walks the same sequence (and
reverses correctly back to the empty draft).
## Out of scope
- Cross-chat history.
- Per-user opt-out for the cycle.
- File-reference / attachment cycling — the cycle continues to reproduce
plain text only, by design.
<details>
<summary>Implementation plan</summary>
# CODAGT-319 Follow-up — Dedicated `/prompts` endpoint
## Context
The merged feature ([#25004](https://github.com/coder/coder/pull/25004)
/ [d32842f](https://github.com/coder/coder/commit/d32842f)) cycles only
through messages already loaded in the in-memory chat store, which is
capped at the first 50 messages of the current page. Long chats and
chats whose oldest turns have rolled out of the page can no longer
recall their full prompt history. This follow-up exposes a dedicated
server endpoint that returns the user-authored prompts in a chat, newest
first, and rewires the composer to use it.
## Design
### Endpoint
`GET /api/experimental/chats/{chat}/prompts?limit=N`
Returns:
```go
type ChatPrompt struct {
ID int64
Text string
}
type ChatPromptsResponse struct {
Prompts []ChatPrompt
}
```
- `limit`: `0..2000`. `0` (the default) → server-side default of 500.
The wire-level default is encoded in SQL as `COALESCE(NULLIF($limit, 0),
500)`. Negatives are rejected upstream by `PositiveInt32`; the handler
only caps the upper bound.
- Auth: parent-chat read in `dbauthz`, mirroring
`GetChatMessagesByChatID`.
- Listed under the experimental router so we can iterate without API
guarantees.
### SQL
The query lives in `coderd/database/queries/chats.sql` as
`GetChatUserPromptsByChatID`:
- Filters `role='user'`, `deleted=false`, `visibility IN
('user','both')` to mirror the composer's "what the user actually typed
and can re-send" contract.
- Guards the lateral with `jsonb_typeof(content) = 'array'` so legacy V0
rows whose content is a scalar JSON string (predates migration `000434`)
are silently excluded instead of raising `"cannot extract elements from
a scalar"`.
- Unrolls `content` JSONB with `jsonb_array_elements WITH ORDINALITY`
and concatenates only `type='text'` parts, preserving original order via
`string_agg(... ORDER BY ordinality)`.
- Casts the result to `text` so sqlc emits a `string` field instead of
`[]byte`.
- Drops whitespace-only prompts via `HAVING string_agg(...) ~ '\S'` so
cycling never lands on a blank entry.
- Orders by `cm.id DESC` (`id` is a sequence, so this is "newest first"
without relying on `created_at`).
### Index
New partial index added in migration `000494`:
```sql
CREATE INDEX idx_chat_messages_user_prompts
ON chat_messages (chat_id, id DESC)
WHERE deleted = false
AND role = 'user'
AND visibility IN ('user', 'both');
```
The partial WHERE clause matches the query's filter exactly, so the
planner can use the index for both filtering and ordering without a sort
step.
### Frontend
- `chatPromptsKey(chatId)` and `chatPromptsQuery(chatId)` factories in
`site/src/api/queries/chats.ts`. `staleTime: 30s`, `enabled: chatId !==
""`. Asks the server for 500 prompts (well below the 2000 max, plenty
for the cycle).
- `ChatPageContent.tsx` replaces the in-memory derivation with
`useQuery(chatPromptsQuery(chatId ?? ""))`. The composer's
`cycleHistorySnapshotRef` already takes a stable snapshot at cycle
entry, so a refetch arriving mid-cycle cannot shift the indexed prompt
out from under the user.
- `getEditableUserMessagePayload` extracts the edit-path text from raw
user-message parts (filter `type === "text"`, join verbatim) instead of
going through `parseMessageContent` / `appendText`, which is built for
assistant streams and intentionally drops whitespace-only chunks.
Without this, cycling and clicking Edit on the same message could
produce different draft text for messages with whitespace-only
interleaved text parts.
- Cache invalidation: `createChatMessage.onSuccess`,
`editChatMessage.onSettled`, and `useChatStore.upsertCacheMessages`
(when at least one upserted message has `role === "user"`) all
invalidate `chatPromptsKey(chatId)`.
### Tests
- `TestGetChatUserPrompts` (`coderd/exp_chats_test.go`) covers:
- `NewestFirstFiltering` — multi-part concatenation, non-text parts
skipped, whitespace-only filtered, soft-deleted excluded, `model`-only
visibility excluded, assistant-role excluded by `cm.role = 'user'`,
legacy V0 scalar row silently excluded by the `jsonb_typeof` guard,
ordering newest first.
- `LimitClampsResults` — explicit `limit=2` returns the two newest
prompts.
- `InvalidLimitRejected` — `limit=5000` is `400 Bad Request`.
- `NotFoundForOtherUsers` — a separate user in the same org gets `404`,
not the prompts.
- `EmptyResultIsJSONArray` — zero-message chat and assistant-only chat
both return `Prompts: []` (non-nil, empty).
- `messageParsing.test.ts` adds a unit test asserting that
`getEditableUserMessagePayload(["hello", " ", "world"])` returns `"hello
world"`, locking in the agreement with the SQL `string_agg`.
- `dbauthz_test.go` adds the
`MethodTestSuite.TestChats/GetChatUserPromptsByChatID` entry, asserting
the parent-chat `policy.ActionRead`.
## Out of scope
- Cross-chat history.
- Per-user opt-out for the cycle.
- File-reference / attachment cycling — the cycle still reproduces plain
text only, by design.
</details>
<details>
<summary>coder-agents-review history</summary>
Four review rounds, eight unique findings, all addressed in this PR
(approved twice). Rebased onto `main` twice after R4: first to pick up
new migrations `000491` / `000492`, then again for
`000493_idx_chat_diff_statuses_url_lower`. The prompts-index migration
was renumbered `000491 → 000493 → 000494` via
`coderd/database/migrations/fix_migration_numbers.sh`; no other diff
changes.
| Round | Head | Outcome |
|---|---|---|
| R1 | `725422ab` | `COMMENTED` — 7 findings (DEREM-1..7) |
| R2 | `ab2a8936` | `COMMENTED` — 1 new (DEREM-10) + 1 reraised
(DEREM-5) |
| R3 | `648c5d1f` | **`APPROVED`** — 7 fixed, DEREM-5 deferred via
#25125 |
| R4 | `93b6f450` | **`APPROVED`** — DEREM-5 also fixed in-PR, #25125
closed |
| ID | Where | Resolution |
|---|---|---|
| DEREM-1 | `chats.sql` | Added `jsonb_typeof(content) = 'array'` guard
against V0 scalar rows |
| DEREM-2 | `exp_chats.go` | Removed dead `limit < 0` branch (SDK
rejects upstream) |
| DEREM-3 | `useChatStore.ts` | Rewrote misleading invalidation comment
|
| DEREM-4 | `exp_chats_test.go` | `NewestFirstFiltering` now inserts an
assistant-role message so the `role='user'` filter is exercised
end-to-end |
| DEREM-5 | `messageParsing.ts` | Rewrote
`getEditableUserMessagePayload` to concatenate text parts verbatim,
mirroring the SQL `string_agg` |
| DEREM-6 | `exp_chats.go` | Tightened swagger doc + error message to
spell out the 0–2000 range |
| DEREM-7 | `exp_chats_test.go` | Added `EmptyResultIsJSONArray` subtest
|
| DEREM-10 | `exp_chats_test.go` | `NewestFirstFiltering` now inserts a
raw V0 scalar-content row; verified locally that removing the guard
makes the test fail |
</details>
---
This PR was created on behalf of @ibetitsmike by Coder Agents.
Part 1: Backend portion of a change broken into 2 PRs.
Part 2: #25077
Adds three new UserAppearanceSettings fields (theme_mode, theme_light,
theme_dark) on top of the existing theme_preference and terminal_font.
Replaces GetUserThemePreference and GetUserTerminalFont with a single
GetUserAppearanceSettings aggregate query. The PUT handler is wrapped in
db.InTx so sync-mode's mode + slot writes can never half-apply.
Adds `dynamicparameters.EvaluateSecretMismatch` as a shared helper on
top of the existing renderer, then wires it into the resolve-autostart
handler so the UI can surface unsatisfied `coder_secret` requirements in
a template alongside parameter mismatch for autostart.
The lifecycle executor changes will land in a follow-up that depend
on this helper. The UI changes that consume the new `secret_mismatch`
field is also a follow-up.
Generated with assistance from Coder Agents.
`AgentChatPageView.tsx` imported `getPersistedSidebarTabId` /
`savePersistedSidebarTabId` from `AgentChatPage.tsx`, which already
imports `AgentChatPageView`, closing a cycle that `pnpm run
lint:circular-deps`
reports but doesn't fail on (dpdm defaults to exit code 0; the script
is missing `--exit-code circular:1`).
Move the three sidebar-tab localStorage helpers and the key prefix into
`utils/sidebarTabStorage.ts` alongside `draftStorage.ts` and the other
per-chat storage modules. Pure code move, no behavior change.
After this change, `pnpm run lint:circular-deps` reports zero cycles.
---------
Signed-off-by: Thomas Kosiewski <tk@coder.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds JSON export actions to the Coder Agents Debug panel so users can download either the current chat's recent debug runs or one expanded run for support sharing.
The export reuses the existing chat debug endpoints and react-query cache, adds Storybook and unit coverage for the JSON envelope, and updates the chat debug logging docs with UI and cURL instructions.
Refs CODAGT-280.
Generated by Coder Agents.
<details>
<summary>Implementation notes</summary>
- Chat-level export fetches full detail for each listed debug run with `queryClient.fetchQuery(chatDebugRun(chatId, run.id))` and writes a single JSON file.
- Run-level export uses the already-loaded detail query data from an expanded run card.
- The JSON envelope includes `version`, `scope`, `exported_at`, `chat_id`, and either `runs` or `run`.
- The chat-level export reflects the current backend list endpoint behavior, up to the 100 newest debug runs.
- Agent-browser dogfooding verified files were downloaded and that `jq` validated the chat-level and run-level JSON contents.
</details>
`TestPatchChatMessage/ChangesModel` hardcoded `"openai"` as the provider
for the override model config. After #25171, the shared chat test
harness registers a single `"openai-compat"` provider by default, so
calling `createAdditionalChatModelConfig(..., "openai", ...)` fails with
HTTP 400 `Chat provider is not configured` before the test can exercise
the model-change path. The subtest was added in #25084 after #25171 was
reviewed, so the harness change and the new hardcoded provider only met
on `main`.
Use `defaultModel.Provider` so the override always matches whatever
provider the harness registered. This mirrors every other call site of
`createAdditionalChatModelConfig` in the file.
Closes https://github.com/coder/internal/issues/1530
Messages with chat file attachments showed a `Copy message` action even
though the action only copied `parsed.markdown` and omitted the
attachment content.
Hide the message copy action whenever a parsed message contains file
attachments, and add regression coverage for both user and assistant
attachment messages.
Refs
https://linear.app/codercom/issue/CODAGT-344/remove-or-fix-copy-buttons-on-file-attachments
Generated by Coder Agents.
## Summary
Updates the deleted workspace banner so its CTA recreates from the
original template instead of sending users to the generic templates
list.
## Changes
- Change the CTA to `Create another from <template>`.
- Link the CTA to the original template workspace create flow.
- Shorten the banner copy to `This workspace has been deleted.`
## Validation
- `pnpm exec biome check --error-on-warnings
src/pages/WorkspacePage/Workspace.tsx
src/pages/WorkspacePage/WorkspaceDeletedBanner.tsx
src/pages/WorkspacePage/WorkspaceDeletedBanner.stories.tsx`
- `pnpm run lint:types`
- `git diff --check`
- `git commit` pre-commit hooks
> 🤖 This PR was created with the help of Coder Agents, and needs a human
review. 🧑💻
Editing a previous user message and selecting a different model in the
picker silently kept using the original model: the selection was dropped
on the frontend, in the SDK, and in the backend, so both the replacement
user message and the assistant turn that followed ran against the old
model.
Plumb the selected model through all three layers (`AgentChatPage`,
`codersdk.EditChatMessageRequest`, `chatd.EditMessageOptions` /
`Server.EditMessage`), defaulting to the original message's model when
the client does not specify one. The existing `InsertChatMessages` CTE
already advances `chats.last_model_config_id` when the inserted
message's model differs, so the assistant turn picks up the new
selection without further changes. The new model is validated inside the
transaction, so an unknown ID rolls the edit back and returns a 400
`Invalid model config ID.`, mirroring the `SendMessage` path.
Refs: CODAGT-345
This change was generated by a Coder agent.
<details>
<summary>Implementation plan</summary>
# CODAGT-345: Editing an earlier message cannot change model
## Problem
When editing a previous user message in a chat, the user can change the
model in the model picker, but the backend keeps using the original
message's model. The model selection is dropped at three layers:
1. **Frontend:** `AgentChatPage.tsx`'s edit branch builds an
`EditChatMessageRequest` that omits `model_config_id`. The new-message
branch (a few lines below) does include it.
2. **SDK:** `codersdk.EditChatMessageRequest` has no `ModelConfigID`
field at all.
3. **Backend:** `chatd.EditMessageOptions` has no model field, and
`Server.EditMessage` always copies the original message's
`ModelConfigID` into the replacement message.
Once the replacement user message is inserted with the original model,
the `InsertChatMessages` CTE leaves `chats.last_model_config_id`
unchanged, so the assistant turn that follows runs against the old
model.
## Fix
Plumb the selected model through all three layers, defaulting to the
original message's model when the client doesn't override it. This
mirrors the `SendMessage` path, which already accepts a
`model_config_id` and validates it via
`resolveSendMessageModelConfigID`.
### Backend
- `codersdk/chats.go`: add `ModelConfigID *uuid.UUID` to
`EditChatMessageRequest`.
- `coderd/x/chatd/chatd.go`:
- Add `ModelConfigID uuid.UUID` to `EditMessageOptions`.
- In `EditMessage`, after fetching the edited message, resolve the
model: if `opts.ModelConfigID != uuid.Nil`, validate it exists with
`tx.GetChatModelConfigByID` (using `chatdModelConfigLookupContext`),
otherwise keep `editedMsg.ModelConfigID.UUID`. Pass the resolved ID into
`newChatMessage(...)`.
- Reuse the existing `ErrInvalidModelConfigID` sentinel.
- `coderd/exp_chats.go` (`patchChatMessage`):
- Read `req.ModelConfigID` (nil-safe), pass into
`chatd.EditMessageOptions`.
- Add a `case xerrors.Is(editErr, chatd.ErrInvalidModelConfigID)` arm
returning 400 `Invalid model config ID.`, matching the
`postChatMessages` handler.
### Frontend
- `site/src/pages/AgentsPage/AgentChatPage.tsx`:
- In the edit branch, set `model_config_id: effectiveSelectedModel ||
undefined` on the `EditChatMessageRequest`.
- On success, persist the chosen model to `lastModelConfigIDStorageKey`
so the next chat from this browser keeps the same default. Mirrors the
new-message branch.
### Generated
- `make site/src/api/typesGenerated.ts` and `make
coderd/apidoc/swagger.json` produce the updated `EditChatMessageRequest`
schema in `typesGenerated.ts`, `coderd/apidoc/{docs.go,swagger.json}`,
and `docs/reference/api/{chats.md,schemas.md}`.
## Tests
- `coderd/x/chatd/chatd_test.go`:
- `TestEditMessageWithModelConfigOverride`: edit with a different model
-> replacement message and `chats.LastModelConfigID` use the new model.
- `TestEditMessagePreservesModelConfigByDefault`: edit without
`ModelConfigID` -> original model preserved.
- `TestEditMessageRejectsUnknownModelConfig`: passes a random UUID ->
`ErrInvalidModelConfigID`, original message still present,
`LastModelConfigID` unchanged (rollback).
- `coderd/exp_chats_test.go` (under `TestPatchChatMessage`):
- `ChangesModel`: end-to-end via SDK; `edited.Message.ModelConfigID` and
`chat.LastModelConfigID` both match the new model.
- `InvalidModelConfigID`: random UUID -> 400 `Invalid model config ID.`.
</details>
closes CODAGT-395
<img width="426" height="480" alt="Screenshot 2026-05-12 at 19 04 03"
src="https://github.com/user-attachments/assets/ba336ecf-3e90-48b0-b5d3-b10499909953"
/>
Adds collapsible section headers to the Agents sidebar, including
visible counts and chevrons for pinned and time-grouped chats.
The section header toggle keeps nested chat expansion state independent,
preserves the existing filter dropdown behavior, and adds Storybook
coverage for counts, collapse or expand interactions, and filter menu
behavior.
Fixes https://linear.app/codercom/issue/CODAGT-350
On OSS or no-license single-org deployments, the Organizations admin
link was hidden because `canViewOrganizationSettings` was gated on
`showOrganizations`, which requires either a multi-org entitlement or >1
org. The page was still reachable via direct URL, but the members view
displayed a raw "Template RBAC is a Premium feature. Contact sales!"
error from the groups API.
Two fixes:
1. Always render the Organizations link inside the `DeploymentDropdown`.
The dropdown itself is only shown to users with admin-level permissions,
so Organizations is effectively gated on having admin access.
2. Remove `groupsByUserIdQuery.error` from the error chain on the
members page. The groups endpoint is gated behind
`templateRBACEnabledMW` on enterprise, returning a 403 on OSS. The
groups data is already optional, so the page renders fine without it.
> Generated by Coder Agents
Adds an Agents General setting to require Cmd/Ctrl+Enter before sending
chat messages. When enabled, plain Enter inserts a newline in agent chat
inputs while the send button remains available.
The preference is now persisted server-side through
`/api/v2/users/{user}/preferences`, alongside the existing user
preference settings, and is applied to both the create-agent input and
existing chat composer. Storybook and API coverage verify the setting,
keyboard behavior, validation, and persistence.
<details>
<summary>Coder Agents notes</summary>
Generated by Coder Agents from a Slack request. Dogfooded with
agent-browser against the Storybook settings and chat input stories.
</details>
The Agents sidebar shows the per-chat turn-end summary as a subtitle,
but the cached `last_turn_summary` is not always in sync with the live
status:
- **Resuming a turn**: when a chat goes back to `running` or `pending`,
the sidebar would keep displaying the previous turn-end label (e.g.,
"Chat is idle") while the agent is already streaming again.
- **Stream end → next summary**: status flips to `waiting`
synchronously, but the new `last_turn_summary` is generated by an async
finalizer that calls the model. For a beat, the row still carries the
previous turn's summary text, which would briefly flash before the new
text arrives.
This PR addresses both:
1. Override the subtitle with `{model} streaming…` while `chat.status`
is `running` or `pending`, matching the same `isStreaming` definition
`ChatPageContent` uses. Errors still take precedence so failure context
is not hidden.
2. Capture the cached summary observed during streaming and suppress an
exact match on the post-stream renders until the new summary lands.
State is adjusted during render so the first post-stream paint already
hides the stale string instead of flashing it for a frame. A 10s timeout
safety net releases the suppression in the corner case where the
regenerated summary equals the previous one byte-for-byte.
<details>
<summary>Decision log</summary>
- **Frontend-only fix**: the server intentionally does not bump
`updated_at` when it writes the new `last_turn_summary` (see
`UpdateChatLastTurnSummary` in `coderd/database/queries/chats.sql`), and
SSE delivers the status flip ahead of the new label. The sidebar knows
the live status, so the rendering layer is the right place to mask the
inconsistency.
- **`isStreaming = running || pending`** mirrors `ChatPageContent.tsx`
so subagents and queued continuations also flip to the live label.
- **State, not refs**, for the suppression bookkeeping: the React
Compiler rejects ref writes during render, and React's "adjust state
during render" pattern is purpose-built for storing information from
previous renders (state setters in render bail out and re-render
synchronously before paint).
- **10s timeout** is a safety net for the rare byte-for-byte equality
case; the equality-based release handles every other shape of update.
- Added Storybook stories covering both fixes
(`ChatStreamingOverridesTurnSummary` and
`StaleTurnSummaryAfterStreamingIsSuppressed`). All existing legacy
subtitle stories still pass because they use terminal statuses.
</details>
> This PR was created by Coder Agents on behalf of @ibetitsmike.