diff --git a/site/src/components/ai-elements/response.tsx b/site/src/components/ai-elements/response.tsx
index e4e56736ad..942991b565 100644
--- a/site/src/components/ai-elements/response.tsx
+++ b/site/src/components/ai-elements/response.tsx
@@ -35,6 +35,10 @@ type MarkdownComponentProps = {
href?: string;
children?: ReactNode;
node?: HastNode;
+ type?: string;
+ checked?: boolean;
+ disabled?: boolean;
+ className?: string;
};
type FileViewerThemeType = "light" | "dark";
@@ -79,10 +83,99 @@ const createComponents = (
{children}
),
+ // Headings scaled for a 13px base using a tight,
+ // Apple-like progression.
+ h1: ({ children }: MarkdownComponentProps) => (
+
+ {children}
+
+ ),
+ h2: ({ children }: MarkdownComponentProps) => (
+
+ {children}
+
+ ),
+ h3: ({ children }: MarkdownComponentProps) => (
+
+ {children}
+
+ ),
+ h4: ({ children }: MarkdownComponentProps) => (
+
+ {children}
+
+ ),
+ h5: ({ children }: MarkdownComponentProps) => (
+
+ {children}
+
+ ),
+ h6: ({ children }: MarkdownComponentProps) => (
+
+ {children}
+
+ ),
+ // GFM task-list checkboxes: render a styled replacement
+ // for the native element.
+ input: ({ type, checked, disabled }: MarkdownComponentProps) => {
+ if (type !== "checkbox") {
+ return ;
+ }
+ return (
+
+ {checked && (
+
+ )}
+
+ );
+ },
+ // Task-list items: remove the default bullet marker.
+ li: ({ className, children }: MarkdownComponentProps) => {
+ const isTask =
+ typeof className === "string" && className.includes("task-list-item");
+ return {children};
+ },
+ // Horizontal rule: reset browser default inset/ridge border
+ // (preflight is disabled) to a clean 1px solid line.
+ hr: () => (
+
+ ),
+ // Table cells: streamdown defaults to text-sm (14px).
+ // Drop the explicit size so cells inherit the 13px base.
+ th: ({ children }: MarkdownComponentProps) => (
+
+ {children}
+ |
+ ),
+ td: ({ children }: MarkdownComponentProps) => (
+ {children} |
+ ),
// Inline code only — fenced blocks are handled by the pre override.
code: ({ children }: MarkdownComponentProps) => (
- {children}
+ {children}{" "}
),
// Fenced code blocks: extract language and content from the HAST
diff --git a/site/src/pages/AgentsPage/AgentDetail.stories.tsx b/site/src/pages/AgentsPage/AgentDetail.stories.tsx
index dbca860700..4848c09088 100644
--- a/site/src/pages/AgentsPage/AgentDetail.stories.tsx
+++ b/site/src/pages/AgentsPage/AgentDetail.stories.tsx
@@ -209,7 +209,9 @@ type Story = StoryObj;
// Stories
// ---------------------------------------------------------------------------
-/** Multi-turn conversation with message history and the chat input visible. */
+/** Multi-turn conversation with rich markdown rendering: headings, tables,
+ * ordered/unordered lists, nested lists, code blocks, blockquotes,
+ * horizontal rules, inline formatting, links, images, and task lists. */
export const WithMessageHistory: Story = {
parameters: {
queries: buildQueries(
@@ -217,10 +219,11 @@ export const WithMessageHistory: Story = {
chat: {
id: CHAT_ID,
...baseChatFields,
- title: "Help me refactor this module",
+ title: "Markdown rendering showcase",
status: "completed",
},
messages: [
+ // -- Turn 1: user asks for a summary --
{
id: 1,
chat_id: CHAT_ID,
@@ -229,10 +232,11 @@ export const WithMessageHistory: Story = {
content: [
{
type: "text",
- text: "Can you help me refactor the authentication module? It's gotten pretty messy.",
+ text: "Give me a comprehensive overview of the auth module refactor. Include tables, lists, code examples, and anything else that would help me understand the plan.",
},
],
},
+ // -- Turn 2: assistant with headings, lists, table, blockquote --
{
id: 2,
chat_id: CHAT_ID,
@@ -241,10 +245,74 @@ export const WithMessageHistory: Story = {
content: [
{
type: "text",
- text: "Sure! I'll start by looking at the current structure. The main issues I can see are:\n\n1. **Mixed concerns** — token validation and session management are interleaved\n2. **No error hierarchy** — all auth errors are treated the same\n3. **Duplicated middleware** — the same checks appear in three places\n\nLet me propose a cleaner separation.",
+ text: [
+ "# Auth Module Refactor Plan",
+ "",
+ "## Current Problems",
+ "",
+ "The existing authentication module has several issues that need addressing:",
+ "",
+ "1. **Mixed concerns** - token validation and session management are interleaved",
+ "2. **No error hierarchy** - all auth errors are treated the same",
+ "3. **Duplicated middleware** - the same checks appear in three places",
+ "4. **Missing observability** - no structured logging or tracing",
+ "5. **Poor test coverage** - only 34% of branches tested",
+ "",
+ "### Impact Matrix",
+ "",
+ "| Component | Severity | Effort | Priority |",
+ "|---|---|---|---|",
+ "| Token Validation | High | Low | P0 |",
+ "| Session Management | High | Medium | P0 |",
+ "| Middleware Dedup | Medium | Low | P1 |",
+ "| Error Hierarchy | Medium | Low | P1 |",
+ "| Observability | Low | Medium | P2 |",
+ "| Test Coverage | Low | High | P2 |",
+ "",
+ "> **Note:** The P0 items are blocking the v2 API release. We should tackle those first before moving to P1 and P2.",
+ "",
+ "---",
+ "",
+ "## Proposed Architecture",
+ "",
+ "The new architecture splits auth into three layers:",
+ "",
+ "- **Transport layer** - extracts credentials from HTTP requests",
+ " - Bearer tokens from `Authorization` header",
+ " - Session cookies from `Cookie` header",
+ " - API keys from `X-API-Key` header",
+ "- **Validation layer** - verifies credentials",
+ " - JWT signature and expiration",
+ " - Session lookup in database",
+ " - API key hash comparison",
+ "- **Authorization layer** - checks permissions",
+ " - Role-based access control (RBAC)",
+ " - Resource-level permissions",
+ "",
+ "### Error Types",
+ "",
+ "The new error hierarchy uses typed sentinel errors:",
+ "",
+ "```go",
+ "var (",
+ " // ErrInvalidToken indicates a malformed or unsigned token.",
+ ' ErrInvalidToken = errors.New("invalid token")',
+ "",
+ " // ErrTokenExpired indicates the token's exp claim is in the past.",
+ ' ErrTokenExpired = errors.New("token expired")',
+ "",
+ " // ErrSessionNotFound means the session ID doesn't exist.",
+ ' ErrSessionNotFound = errors.New("session not found")',
+ "",
+ " // ErrInsufficientPermissions means the user lacks required roles.",
+ ' ErrInsufficientPermissions = errors.New("insufficient permissions")',
+ ")",
+ "```",
+ ].join("\n"),
},
],
},
+ // -- Turn 3: user follow-up --
{
id: 3,
chat_id: CHAT_ID,
@@ -253,10 +321,11 @@ export const WithMessageHistory: Story = {
content: [
{
type: "text",
- text: "That sounds right. Can you start with the token validation? I want to make sure we handle JWT expiration properly.",
+ text: "Can you show me the token validation code and a comparison of the old vs new approach?",
},
],
},
+ // -- Turn 4: assistant with code, table, nested list, task list --
{
id: 4,
chat_id: CHAT_ID,
@@ -265,10 +334,72 @@ export const WithMessageHistory: Story = {
content: [
{
type: "text",
- text: "Here's the refactored token validation:\n\n```go\nfunc ValidateToken(ctx context.Context, token string) (*Claims, error) {\n claims, err := parseToken(token)\n if err != nil {\n return nil, ErrInvalidToken\n }\n if claims.ExpiresAt.Before(time.Now()) {\n return nil, ErrTokenExpired\n }\n return claims, nil\n}\n```\n\nKey changes:\n- Separated parsing from expiration checking\n- Added typed errors (`ErrInvalidToken`, `ErrTokenExpired`) so callers can distinguish between a malformed token and an expired one\n- The context parameter allows us to add tracing later",
+ text: [
+ "## Token Validation (Refactored)",
+ "",
+ "Here is the new `ValidateToken` function with proper error handling and context propagation:",
+ "",
+ "```go",
+ "func ValidateToken(ctx context.Context, token string) (*Claims, error) {",
+ " claims, err := parseToken(token)",
+ " if err != nil {",
+ ' return nil, fmt.Errorf("%w: %v", ErrInvalidToken, err)',
+ " }",
+ " if claims.ExpiresAt.Before(time.Now()) {",
+ " return nil, ErrTokenExpired",
+ " }",
+ " return claims, nil",
+ "}",
+ "```",
+ "",
+ "### Old vs New Comparison",
+ "",
+ "| Aspect | Old Implementation | New Implementation |",
+ "|---|---|---|",
+ "| Error types | Generic `error` | Typed sentinel errors |",
+ "| Token parsing | Inline in handler | Extracted to `parseToken()` |",
+ "| Expiry check | Mixed with validation | Separate step with `ErrTokenExpired` |",
+ "| Context | Not used | Passed through for tracing |",
+ "| Testability | Requires HTTP server | Pure function, unit-testable |",
+ "",
+ "### Key Changes",
+ "",
+ "The refactored version improves several areas:",
+ "",
+ "1. **Separation of concerns**",
+ " - Parsing is isolated from expiration checking",
+ " - Each failure mode has its own error type",
+ " - Callers can match on `errors.Is(err, ErrTokenExpired)` to refresh tokens",
+ "2. **Observability**",
+ " - Context carries trace spans",
+ " - Errors wrap the original cause with `%w`",
+ "3. **Testing**",
+ " - Pure function with no HTTP dependency",
+ " - Table-driven tests for each error path",
+ "",
+ "> **Warning:** The old `ValidateAndRefresh` function is now deprecated. Callers should migrate to the new two-step pattern:",
+ "> ```go",
+ "> claims, err := ValidateToken(ctx, token)",
+ "> if errors.Is(err, ErrTokenExpired) {",
+ "> newToken, err := RefreshToken(ctx, token)",
+ "> }",
+ "> ```",
+ "",
+ "---",
+ "",
+ "#### Migration Checklist",
+ "",
+ "- [x] Extract `parseToken()` helper",
+ "- [x] Define sentinel error types",
+ "- [x] Add context parameter",
+ "- [ ] Update all callers in `coderd/`",
+ "- [ ] Add table-driven unit tests",
+ "- [ ] Remove deprecated `ValidateAndRefresh`",
+ ].join("\n"),
},
],
},
+ // -- Turn 5: user asks about middleware --
{
id: 5,
chat_id: CHAT_ID,
@@ -277,10 +408,11 @@ export const WithMessageHistory: Story = {
content: [
{
type: "text",
- text: "Looks good. Now what about the middleware deduplication?",
+ text: "Now show me the middleware consolidation with the full config and some inline code references.",
},
],
},
+ // -- Turn 6: assistant with code, inline code, links, images, nested blockquote --
{
id: 6,
chat_id: CHAT_ID,
@@ -289,7 +421,106 @@ export const WithMessageHistory: Story = {
content: [
{
type: "text",
- text: "I've consolidated the three middleware instances into a single composable chain:\n\n```go\nfunc AuthMiddleware(opts ...AuthOption) func(http.Handler) http.Handler {\n cfg := defaultAuthConfig()\n for _, opt := range opts {\n opt(&cfg)\n }\n return func(next http.Handler) http.Handler {\n return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n claims, err := ValidateToken(r.Context(), extractToken(r))\n if err != nil {\n cfg.ErrorHandler(w, r, err)\n return\n }\n ctx := context.WithValue(r.Context(), claimsKey, claims)\n next.ServeHTTP(w, r.WithContext(ctx))\n })\n }\n}\n```\n\nThe functional options pattern lets each route customize behavior (e.g. optional auth, different error responses) without duplicating the core logic.",
+ text: [
+ "## Middleware Consolidation",
+ "",
+ "I consolidated the three middleware instances into a single composable chain using the *functional options* pattern. The `AuthMiddleware` function accepts variadic `AuthOption` values to customize behavior per-route.",
+ "",
+ "### Configuration Struct",
+ "",
+ "```go",
+ "type authConfig struct {",
+ " // RequireAuth rejects unauthenticated requests when true.",
+ " RequireAuth bool",
+ " // ErrorHandler is called when authentication fails.",
+ " ErrorHandler func(http.ResponseWriter, *http.Request, error)",
+ " // AllowedRoles restricts access to specific roles.",
+ " AllowedRoles []string",
+ " // TokenSources defines where to look for credentials.",
+ " TokenSources []TokenSource",
+ "}",
+ "",
+ "func defaultAuthConfig() authConfig {",
+ " return authConfig{",
+ " RequireAuth: true,",
+ " ErrorHandler: defaultErrorHandler,",
+ " AllowedRoles: nil, // all roles",
+ " TokenSources: []TokenSource{BearerToken, SessionCookie},",
+ " }",
+ "}",
+ "```",
+ "",
+ "### Middleware Function",
+ "",
+ "```go",
+ "func AuthMiddleware(opts ...AuthOption) func(http.Handler) http.Handler {",
+ " cfg := defaultAuthConfig()",
+ " for _, opt := range opts {",
+ " opt(&cfg)",
+ " }",
+ " return func(next http.Handler) http.Handler {",
+ " return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {",
+ " claims, err := ValidateToken(r.Context(), extractToken(r))",
+ " if err != nil {",
+ " cfg.ErrorHandler(w, r, err)",
+ " return",
+ " }",
+ " ctx := context.WithValue(r.Context(), claimsKey, claims)",
+ " next.ServeHTTP(w, r.WithContext(ctx))",
+ " })",
+ " }",
+ "}",
+ "```",
+ "",
+ "### Usage Examples",
+ "",
+ "Here is how different routes use the middleware:",
+ "",
+ "| Route | Options | Behavior |",
+ "|---|---|---|",
+ '| `GET /api/users` | `WithRoles("admin")` | Requires admin role |',
+ "| `GET /api/me` | *(defaults)* | Any authenticated user |",
+ "| `GET /api/health` | `WithOptionalAuth()` | Auth is optional |",
+ "| `POST /api/webhooks` | `WithAPIKeyOnly()` | Only API key auth |",
+ "",
+ "### Inline References",
+ "",
+ "The key types involved are:",
+ "",
+ "- `AuthOption` is a `func(*authConfig)` that mutates the config",
+ "- `TokenSource` is an enum: `BearerToken`, `SessionCookie`, or `APIKey`",
+ "- `Claims` holds the decoded JWT payload including `sub`, `exp`, and `roles`",
+ "",
+ "For more details, see the [Go middleware patterns](https://pkg.go.dev/net/http) documentation and the ~~old middleware README~~ (now removed).",
+ "",
+ "> **Tip:** You can compose multiple options together:",
+ ">",
+ "> ```go",
+ "> r.Use(AuthMiddleware(",
+ '> WithRoles("admin", "editor"),',
+ "> WithCustomErrorHandler(jsonErrorHandler),",
+ "> ))",
+ "> ```",
+ ">",
+ "> This replaces the three separate middlewares we had before.",
+ "",
+ "---",
+ "",
+ "#### Performance Benchmarks",
+ "",
+ "| Benchmark | Old (ns/op) | New (ns/op) | Delta |",
+ "|---|---:|---:|---:|",
+ "| `BenchmarkValidateToken` | 4,521 | 1,203 | -73.4% |",
+ "| `BenchmarkMiddlewareChain` | 12,887 | 3,456 | -73.2% |",
+ "| `BenchmarkSessionLookup` | 89,102 | 45,330 | -49.1% |",
+ "| `BenchmarkFullAuthFlow` | 102,340 | 48,912 | -52.2% |",
+ "",
+ "The performance improvement comes mainly from:",
+ "",
+ "1. Removing redundant token parsing (was done 3x per request)",
+ "2. Caching parsed claims in context",
+ "3. Using `sync.Pool` for the JWT parser",
+ ].join("\n"),
},
],
},
diff --git a/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx b/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx
index dcb63cd3c9..af8151223a 100644
--- a/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx
+++ b/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx
@@ -72,7 +72,7 @@ export const AgentDetailTopBar: FC = ({
const navigate = useNavigate();
return (
-
+
{/* Mobile back button */}
)}