Compare commits

...

13 Commits

Author SHA1 Message Date
Mathias Fredriksson 61175ffc07 fix(agent/reaper): eliminate Wait4 race using go-reap fork
The reaper goroutine and ForkReap both called Wait4, racing to
consume the child's waitable state. When the reaper won, ForkReap
got ECHILD.

Switch to coder/go-reap's ReapChildrenWithStatus, which reports
both PID and WaitStatus on a channel. ForkReap reads from this
channel instead of calling Wait4 itself.

Also register signal handlers synchronously before spawning the
forwarding goroutine so no signal is lost between ForkExec and
the handler being ready.

Depends on https://github.com/coder/go-reap/pull/1
2026-03-10 15:58:15 +00:00
Mathias Fredriksson 9eb68d1b9c fix(agent/reaper): run reaper tests in isolated subprocesses
Tests that call ForkReap or send signals to their own process now
re-exec as isolated subprocesses. This prevents ForkReap's
syscall.ForkExec and process-directed signals from interfering
with the parent test binary or other tests running in parallel.

Also:
- Wait for the reaper goroutine to fully exit between subtests
  to prevent overlapping reapers from competing on Wait4(-1).
- Register signal handlers synchronously before spawning the
  forwarding goroutine so no signal is lost between ForkExec
  and the handler being ready.
2026-03-10 15:57:10 +00:00
Danielle Maywood d61772dc52 refactor(site): separate AgentsPage and AgentDetail into container/view pairs (#22812) 2026-03-10 12:09:48 +00:00
Cian Johnston c933ddcffd fix(agents): persist system prompt server-side instead of localStorage (#22857)
## Problem

The Admin → Agents → System Prompt textarea saved only to the browser's
`localStorage`. The value was never sent to the backend, never stored in
the database, and never injected into chats. Entering text, clicking
Save, and refreshing the page showed no changes — the prompt was
effectively a no-op.

## Root Cause

Three disconnected layers:
1. **Frontend** wrote to `localStorage`, never called an API.
2. **`handleCreateChat`** never read `savedSystemPrompt`.
3. **Backend** hardcoded `chatd.DefaultSystemPrompt` on every chat
creation — no field in `CreateChatRequest` accepted a custom prompt.

## Changes

### Database
- Added `GetChatSystemPrompt` / `UpsertChatSystemPrompt` queries on the
existing `site_configs` table (no migration needed).

### API
- `GET /api/experimental/chats/system-prompt` — returns the configured
prompt (any authenticated user).
- `PUT /api/experimental/chats/system-prompt` — sets the prompt
(admin-only, `rbac: deployment_config update`).
- Input validation: max 32 KiB prompt length.

### Backend
- `resolvedChatSystemPrompt(ctx)` checks for a custom prompt in the DB,
falls back to `chatd.DefaultSystemPrompt` when empty/unset.
- Logs a warning on DB errors instead of silently swallowing them.
- Replaced the hardcoded `defaultChatSystemPrompt()` call in chat
creation.

### Frontend
- Replaced `localStorage` read/write with React Query
`useQuery`/`useMutation` backed by the new endpoints.
- Fixed `useEffect` draft sync to avoid clobbering in-progress user
edits on refetch.
- Added `try/catch` error handling on save (draft stays dirty for
retry).
- Save button disabled during mutation (`isSavingSystemPrompt`).
- Query key follows kebab-case convention (`chat-system-prompt`).

### UX
- Added hint: "When empty, the built-in default prompt is used."

### Tests
- `TestChatSystemPrompt`: GET returns empty when unset, admin can set,
non-admin gets 403.
- dbauthz `TestMethodTestSuite` coverage for both new querier methods.
2026-03-10 11:46:53 +00:00
Atif Ali a21f00d250 chore(ci): tighten permissions for AI workflows (#22471) 2026-03-10 16:43:36 +05:00
Mathias Fredriksson 3167908358 fix(site): fix chat input button icon sizing and centering (#22882)
The Button icon variant applies [&>svg]:size-icon-sm (18px) and
the base applies [&>svg]:p-0.5, both of which silently override
h-*/w-* set directly on child SVGs. This caused the stop icon to
render at 18px instead of 12px and the send arrow to shift
off-center due to uncleared padding.

Pin each icon size via !important on the parent className so the
values are deterministic regardless of Tailwind class order:

- Attach: !size-icon-sm (18px, unchanged visual)
- Stop: !size-3 (12px, matches original intent)
- Send: !size-5 (20px, matches prior visual after padding)

Add Streaming and StreamingInterruptPending stories for the stop
button.
2026-03-10 12:57:08 +02:00
Hugo Dutka 45f62d1487 fix(chatd): update the spawn_agent tool description (#22880)
I keep running into the same couple of issues with subagents:

- when I request code analysis, the main agent tends to spawn subagents
to read files and output them verbatim to the main chat
- when I request to implement a feature, the main agent often spawns
subagents that edit the same files and conflict with one another,
reverting each other's changes.

This PR updates the `spawn_agent` tool description to mitigate those
issues.
2026-03-10 11:46:50 +01:00
Danielle Maywood b850d40db8 fix(site): remove redundant success toasts from agents feature (#22884) 2026-03-10 10:32:27 +00:00
Mathias Fredriksson 73bf8478d8 fix(cli): fix flaky TestGitSSH/Local_SSH_Keys on Windows CI (#22883)
The `TestGitSSH/Local_SSH_Keys` test was flaking on Windows CI with a
context deadline exceeded error when calling `client.GitSSHKey(ctx)`.

Two issues contributed to the flake:

1. `prepareTestGitSSH` called `coderdtest.AwaitWorkspaceAgents` without
   passing the caller's context. This created a separate internal 25s
   timeout, wasting time budget independently of the setup context.
   Changed to use `NewWorkspaceAgentWaiter(...).WithContext(ctx).Wait()`
   so the agent wait shares the caller's timeout.

2. The `Local SSH Keys` subtest used `WaitLong` (25s) for its setup
   context, but this subtest does more work than `Dial` (runs the
   command twice). Bumped to `WaitSuperLong` (60s) to give slow
   Windows CI runners enough time.

Fixes coder/internal#770
2026-03-10 12:12:15 +02:00
Mathias Fredriksson 41c505f03b fix(cli): handle ignored errors in ssh and scaletest commands (#22852)
Handle errors that were previously assigned to blank identifiers in the
`cli/` package.

- ssh.go: Log ExistsViaCoderConnect DNS lookup error at debug level
  instead of silently discarding it. Fallthrough behavior preserved.
- exp_scaletest_llmmock.go: Log srv.Stop() error via the existing
  logger instead of discarding it.
2026-03-10 12:08:40 +02:00
Mathias Fredriksson abdfadf8cb build(Makefile): fix lint/go recipe by using bash subshell (#22874)
The `lint/go` recipe used `$(shell)` inside a recipe to extract the
golangci-lint version. When `MAKE_TIMED=1` (set by pre-commit/pre-push),
make expands `.SHELLFLAGS = $@ -ceu` for `$(shell)` calls, passing the
target name as the first argument to `timed-shell.sh`. Since the target
name doesn't start with `-`, the timing code path runs and its banner
output contaminates the captured value, causing intermittent failures:

```
bash: line 3: lint/go: No such file or directory
```

Replace with bash command substitution (`$$()`), which is the correct
approach under `.ONESHELL` and avoids the `SHELL`/`.SHELLFLAGS`
interaction entirely. Also replaces deprecated `egrep` with `grep -oE`.
2026-03-10 12:07:44 +02:00
Danny Kopping d936a99e6b fix(cli): error when CODER_SESSION_TOKEN env var is set during login (#22879)
_Disclaimer: created with Opus 4.6 and Coder Agents._

## Problem

When `CODER_SESSION_TOKEN` is set as an environment variable with an
invalid value, `coder login` fails with a confusing error:

```
error: Trace=[create api key: ]
You are signed out or your session has expired. Please sign in again to continue.
Suggestion: Try logging in using 'coder login'.
```

The suggestion to run `coder login` is what the user just did, making it
circular and unhelpful.

## Root cause

The `--token` flag is mapped to `CODER_SESSION_TOKEN` via serpent. When
the env var is set, `coder login` picks it up as the session token and
tries to use it to create a new API key, which fails because the token
is invalid. Even if login were to succeed and write a new token to disk,
subsequent commands would still use the env var (which takes precedence
over the on-disk token), so the user would remain stuck.

## Fix

Before attempting login, check if `CODER_SESSION_TOKEN` is set in the
environment. If so, return a clear error telling the user to unset it:

```
the environment variable CODER_SESSION_TOKEN is set, which takes precedence
over the session token stored on disk. Please unset it and try again.

    unset CODER_SESSION_TOKEN
```

## Testing

Added `TestLogin/SessionTokenEnvVar` that verifies the error is returned
when the env var is set.
2026-03-10 09:41:05 +00:00
Zach 14341edfc2 fix(cli): fix coder login token failing without --url flag (#22742)
Previously `coder login token` didn't load the server URL from config,
so it always required --url or CODER_URL when using the keyring to store
the session token. This command would only print out the token when
already logged in to a deployment and file storage is used to store the
session token (keyring is the default on Windows/macOS). It would also
print out an incorrect token when --url was specified and the session
token stored on disk was for a different deployment that the user logged
into.

This change fixes all of these issues, and also errors out when using
session token file storage with a `--url` argument that doesn't match
the stored config URL, since the file only stores one token and would
silently return the wrong one.

See https://github.com/coder/coder/issues/22733 for a table of the
before/after behaviors.
2026-03-10 08:57:27 +01:00
43 changed files with 2122 additions and 613 deletions
@@ -19,6 +19,9 @@ on:
default: ""
type: string
permissions:
contents: read
jobs:
classify-severity:
name: AI Severity Classification
@@ -32,7 +35,6 @@ jobs:
permissions:
contents: read
issues: write
actions: write
steps:
- name: Determine Issue Context
+3 -1
View File
@@ -31,6 +31,9 @@ on:
default: ""
type: string
permissions:
contents: read
jobs:
code-review:
name: AI Code Review
@@ -51,7 +54,6 @@ jobs:
permissions:
contents: read
pull-requests: write
actions: write
steps:
- name: Check if secrets are available
+3 -1
View File
@@ -34,6 +34,9 @@ on:
default: ""
type: string
permissions:
contents: read
jobs:
doc-check:
name: Analyze PR for Documentation Updates Needed
@@ -56,7 +59,6 @@ jobs:
permissions:
contents: read
pull-requests: write
actions: write
steps:
- name: Check if secrets are available
+3 -1
View File
@@ -26,6 +26,9 @@ on:
default: "traiage"
type: string
permissions:
contents: read
jobs:
traiage:
name: Triage GitHub Issue with Claude Code
@@ -38,7 +41,6 @@ jobs:
permissions:
contents: read
issues: write
actions: write
steps:
# This is only required for testing locally using nektos/act, so leaving commented out.
+1 -1
View File
@@ -636,7 +636,7 @@ lint/ts: site/node_modules/.installed
lint/go:
./scripts/check_enterprise_imports.sh
./scripts/check_codersdk_imports.sh
linter_ver=$(shell egrep -o 'GOLANGCI_LINT_VERSION=\S+' dogfood/coder/Dockerfile | cut -d '=' -f 2)
linter_ver=$$(grep -oE 'GOLANGCI_LINT_VERSION=\S+' dogfood/coder/Dockerfile | cut -d '=' -f 2)
go run github.com/golangci/golangci-lint/cmd/golangci-lint@v$$linter_ver run
go tool github.com/coder/paralleltestctx/cmd/paralleltestctx -custom-funcs="testutil.Context" ./...
.PHONY: lint/go
+31 -8
View File
@@ -2,6 +2,7 @@ package reaper
import (
"os"
"sync"
"github.com/hashicorp/go-reap"
@@ -42,20 +43,42 @@ func WithLogger(logger slog.Logger) Option {
}
}
// WithDone sets a channel that, when closed, stops the reaper
// WithReaperStop sets a channel that, when closed, stops the reaper
// goroutine. Callers that invoke ForkReap more than once in the
// same process (e.g. tests) should use this to prevent goroutine
// accumulation.
func WithDone(ch chan struct{}) Option {
func WithReaperStop(ch chan struct{}) Option {
return func(o *options) {
o.Done = ch
o.ReaperStop = ch
}
}
// WithReaperStopped sets a channel that is closed after the
// reaper goroutine has fully exited.
func WithReaperStopped(ch chan struct{}) Option {
return func(o *options) {
o.ReaperStopped = ch
}
}
// WithReapLock sets a mutex shared between the reaper and Wait4.
// The reaper holds the write lock while reaping, and ForkReap
// holds the read lock during Wait4, preventing the reaper from
// stealing the child's exit status. This is only needed for
// tests with instant-exit children where the race window is
// large.
func WithReapLock(mu *sync.RWMutex) Option {
return func(o *options) {
o.ReapLock = mu
}
}
type options struct {
ExecArgs []string
PIDs reap.PidCh
CatchSignals []os.Signal
Logger slog.Logger
Done chan struct{}
ExecArgs []string
PIDs reap.PidCh
CatchSignals []os.Signal
Logger slog.Logger
ReaperStop chan struct{}
ReaperStopped chan struct{}
ReapLock *sync.RWMutex
}
+97 -43
View File
@@ -18,40 +18,83 @@ import (
"github.com/coder/coder/v2/testutil"
)
// withDone returns an option that stops the reaper goroutine when t
// completes, preventing goroutine accumulation across subtests.
func withDone(t *testing.T) reaper.Option {
// subprocessEnvKey is set when a test re-execs itself as an
// isolated subprocess. Tests that call ForkReap or send signals
// to their own process check this to decide whether to run real
// test logic or launch the subprocess and wait for it.
const subprocessEnvKey = "CODER_REAPER_TEST_SUBPROCESS"
// runSubprocess re-execs the current test binary in a new process
// running only the named test. This isolates ForkReap's
// syscall.ForkExec and any process-directed signals (e.g. SIGINT)
// from the parent test binary, making these tests safe to run in
// CI and alongside other tests.
//
// Returns true inside the subprocess (caller should proceed with
// the real test logic). Returns false in the parent after the
// subprocess exits successfully (caller should return).
func runSubprocess(t *testing.T) bool {
t.Helper()
done := make(chan struct{})
t.Cleanup(func() { close(done) })
return reaper.WithDone(done)
if os.Getenv(subprocessEnvKey) == "1" {
return true
}
ctx := testutil.Context(t, testutil.WaitMedium)
//nolint:gosec // Test-controlled arguments.
cmd := exec.CommandContext(ctx, os.Args[0],
"-test.run=^"+t.Name()+"$",
"-test.v",
)
cmd.Env = append(os.Environ(), subprocessEnvKey+"=1")
out, err := cmd.CombinedOutput()
t.Logf("Subprocess output:\n%s", out)
require.NoError(t, err, "subprocess failed")
return false
}
// TestReap checks that's the reaper is successfully reaping
// exited processes and passing the PIDs through the shared
// channel.
//
//nolint:paralleltest
// withDone returns options that stop the reaper goroutine when t
// completes and wait for it to fully exit, preventing
// overlapping reapers across sequential subtests.
func withDone(t *testing.T) []reaper.Option {
t.Helper()
stop := make(chan struct{})
stopped := make(chan struct{})
t.Cleanup(func() {
close(stop)
<-stopped
})
return []reaper.Option{
reaper.WithReaperStop(stop),
reaper.WithReaperStopped(stopped),
}
}
// TestReap checks that the reaper successfully reaps exited
// processes and passes their PIDs through the shared channel.
func TestReap(t *testing.T) {
// Don't run the reaper test in CI. It does weird
// things like forkexecing which may have unintended
// consequences in CI.
if testutil.InCI() {
t.Skip("Detected CI, skipping reaper tests")
t.Parallel()
if !runSubprocess(t) {
return
}
pids := make(reap.PidCh, 1)
exitCode, err := reaper.ForkReap(
opts := append([]reaper.Option{
reaper.WithPIDCallback(pids),
// Provide some argument that immediately exits.
reaper.WithExecArgs("/bin/sh", "-c", "exit 0"),
withDone(t),
)
require.NoError(t, err)
require.Equal(t, 0, exitCode)
reaper.WithExecArgs("/bin/sh", "-c", "exec sleep inf >/dev/null 2>&1"),
}, withDone(t)...)
// Run ForkReap in the background so the child stays alive
// while we verify that the reaper forwards other PIDs.
go func() {
_, _ = reaper.ForkReap(opts...)
}()
cmd := exec.Command("tail", "-f", "/dev/null")
err = cmd.Start()
err := cmd.Start()
require.NoError(t, err)
cmd2 := exec.Command("tail", "-f", "/dev/null")
@@ -66,7 +109,7 @@ func TestReap(t *testing.T) {
expectedPIDs := []int{cmd.Process.Pid, cmd2.Process.Pid}
for i := 0; i < len(expectedPIDs); i++ {
for range len(expectedPIDs) {
select {
case <-time.After(testutil.WaitShort):
t.Fatalf("Timed out waiting for process")
@@ -74,12 +117,17 @@ func TestReap(t *testing.T) {
require.Contains(t, expectedPIDs, pid)
}
}
// Test cleanup stops the reaper, which terminates ForkReap.
// The child's stdout/stderr are redirected to /dev/null so
// CombinedOutput won't hang on inherited pipes.
}
//nolint:paralleltest
//nolint:tparallel // Subtests must be sequential, each starts its own reaper.
func TestForkReapExitCodes(t *testing.T) {
if testutil.InCI() {
t.Skip("Detected CI, skipping reaper tests")
t.Parallel()
if !runSubprocess(t) {
return
}
tests := []struct {
@@ -95,25 +143,27 @@ func TestForkReapExitCodes(t *testing.T) {
{"SIGTERM", "kill -15 $$", 128 + 15},
}
//nolint:paralleltest // Subtests must be sequential, each starts its own reaper.
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
exitCode, err := reaper.ForkReap(
opts := append([]reaper.Option{
reaper.WithExecArgs("/bin/sh", "-c", tt.command),
withDone(t),
)
}, withDone(t)...)
exitCode, err := reaper.ForkReap(opts...)
require.NoError(t, err)
require.Equal(t, tt.expectedCode, exitCode, "exit code mismatch for %q", tt.command)
})
}
}
//nolint:paralleltest // Signal handling.
// TestReapInterrupt verifies that ForkReap forwards caught signals
// to the child process. The test sends SIGINT to its own process
// and checks that the child receives it. Running in a subprocess
// ensures SIGINT cannot kill the parent test binary.
func TestReapInterrupt(t *testing.T) {
// Don't run the reaper test in CI. It does weird
// things like forkexecing which may have unintended
// consequences in CI.
if testutil.InCI() {
t.Skip("Detected CI, skipping reaper tests")
t.Parallel()
if !runSubprocess(t) {
return
}
errC := make(chan error, 1)
@@ -126,24 +176,28 @@ func TestReapInterrupt(t *testing.T) {
defer signal.Stop(usrSig)
go func() {
exitCode, err := reaper.ForkReap(
opts := append([]reaper.Option{
reaper.WithPIDCallback(pids),
reaper.WithCatchSignals(os.Interrupt),
withDone(t),
// Signal propagation does not extend to children of children, so
// we create a little bash script to ensure sleep is interrupted.
reaper.WithExecArgs("/bin/sh", "-c", fmt.Sprintf("pid=0; trap 'kill -USR2 %d; kill -TERM $pid' INT; sleep 10 &\npid=$!; kill -USR1 %d; wait", os.Getpid(), os.Getpid())),
)
reaper.WithExecArgs("/bin/sh", "-c", fmt.Sprintf(
"pid=0; trap 'kill -USR2 %d; kill -TERM $pid' INT; sleep 10 &\npid=$!; kill -USR1 %d; wait",
os.Getpid(), os.Getpid(),
)),
}, withDone(t)...)
exitCode, err := reaper.ForkReap(opts...)
// The child exits with 128 + SIGTERM (15) = 143, but the trap catches
// SIGINT and sends SIGTERM to the sleep process, so exit code varies.
_ = exitCode
errC <- err
}()
require.Equal(t, <-usrSig, syscall.SIGUSR1)
require.Equal(t, syscall.SIGUSR1, <-usrSig)
err := syscall.Kill(os.Getpid(), syscall.SIGINT)
require.NoError(t, err)
require.Equal(t, <-usrSig, syscall.SIGUSR2)
require.Equal(t, syscall.SIGUSR2, <-usrSig)
require.NoError(t, <-errC)
}
+49 -31
View File
@@ -19,31 +19,36 @@ func IsInitProcess() bool {
return os.Getpid() == 1
}
func catchSignals(logger slog.Logger, pid int, sigs []os.Signal) {
// startSignalForwarding registers signal handlers synchronously
// then forwards caught signals to the child in a background
// goroutine. Registering before the goroutine starts ensures no
// signal is lost between ForkExec and the handler being ready.
func startSignalForwarding(logger slog.Logger, pid int, sigs []os.Signal) {
if len(sigs) == 0 {
return
}
sc := make(chan os.Signal, 1)
signal.Notify(sc, sigs...)
defer signal.Stop(sc)
logger.Info(context.Background(), "reaper catching signals",
slog.F("signals", sigs),
slog.F("child_pid", pid),
)
for {
s := <-sc
sig, ok := s.(syscall.Signal)
if ok {
logger.Info(context.Background(), "reaper caught signal, killing child process",
slog.F("signal", sig.String()),
slog.F("child_pid", pid),
)
_ = syscall.Kill(pid, sig)
go func() {
defer signal.Stop(sc)
for s := range sc {
sig, ok := s.(syscall.Signal)
if ok {
logger.Info(context.Background(), "reaper caught signal, killing child process",
slog.F("signal", sig.String()),
slog.F("child_pid", pid),
)
_ = syscall.Kill(pid, sig)
}
}
}
}()
}
// ForkReap spawns a goroutine that reaps children. In order to avoid
@@ -64,7 +69,17 @@ func ForkReap(opt ...Option) (int, error) {
o(opts)
}
go reap.ReapChildren(opts.PIDs, nil, opts.Done, nil)
// Buffered so the reaper won't block on send between our
// child exiting and the reaper stopping.
statuses := make(reap.StatusCh, 8)
go func() {
reap.ReapChildrenWithStatus(statuses, nil, opts.ReaperStop, nil)
close(statuses)
if opts.ReaperStopped != nil {
close(opts.ReaperStopped)
}
}()
pwd, err := os.Getwd()
if err != nil {
@@ -90,25 +105,28 @@ func ForkReap(opt ...Option) (int, error) {
return 1, xerrors.Errorf("fork exec: %w", err)
}
go catchSignals(opts.Logger, pid, opts.CatchSignals)
startSignalForwarding(opts.Logger, pid, opts.CatchSignals)
var wstatus syscall.WaitStatus
_, err = syscall.Wait4(pid, &wstatus, 0, nil)
for xerrors.Is(err, syscall.EINTR) {
_, err = syscall.Wait4(pid, &wstatus, 0, nil)
for cs := range statuses {
if opts.PIDs != nil && cs.Pid != pid {
opts.PIDs <- cs.Pid
}
if cs.Pid != pid {
continue
}
// Convert wait status to exit code using standard Unix conventions:
// - Normal exit: use the exit code
// - Signal termination: use 128 + signal number
ws := syscall.WaitStatus(cs.Status)
switch {
case ws.Exited():
return ws.ExitStatus(), nil
case ws.Signaled():
return 128 + int(ws.Signal()), nil
default:
return 1, nil
}
}
// Convert wait status to exit code using standard Unix conventions:
// - Normal exit: use the exit code
// - Signal termination: use 128 + signal number
var exitCode int
switch {
case wstatus.Exited():
exitCode = wstatus.ExitStatus()
case wstatus.Signaled():
exitCode = 128 + int(wstatus.Signal())
default:
exitCode = 1
}
return exitCode, err
return 1, xerrors.New("reaper exited before child process was reaped")
}
+3 -1
View File
@@ -57,7 +57,9 @@ func (*RootCmd) scaletestLLMMock() *serpent.Command {
return xerrors.Errorf("start mock LLM server: %w", err)
}
defer func() {
_ = srv.Stop()
if err := srv.Stop(); err != nil {
logger.Error(ctx, "failed to stop mock LLM server", slog.Error(err))
}
}()
_, _ = fmt.Fprintf(inv.Stdout, "Mock LLM API server started on %s\n", srv.APIAddress())
+2 -2
View File
@@ -58,7 +58,7 @@ func prepareTestGitSSH(ctx context.Context, t *testing.T) (*agentsdk.Client, str
_ = agenttest.New(t, client.URL, r.AgentToken, func(o *agent.Options) {
o.Client = agentClient
})
_ = coderdtest.AwaitWorkspaceAgents(t, client, r.Workspace.ID)
_ = coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).WithContext(ctx).Wait()
return agentClient, r.AgentToken, pubkey
}
@@ -167,7 +167,7 @@ func TestGitSSH(t *testing.T) {
require.NoError(t, err)
writePrivateKeyToFile(t, idFile, privkey)
setupCtx := testutil.Context(t, testutil.WaitLong)
setupCtx := testutil.Context(t, testutil.WaitSuperLong)
client, token, coderPubkey := prepareTestGitSSH(setupCtx, t)
authkey := make(chan gossh.PublicKey, 1)
+34 -1
View File
@@ -356,6 +356,20 @@ func (r *RootCmd) login() *serpent.Command {
return nil
}
// If CODER_SESSION_TOKEN is set in the environment, abort login.
// The env var takes precedence over a token stored on disk, so
// even if we complete login and write a new token to the session
// file, subsequent CLI commands would still use the environment
// variable value.
if inv.Environ.Get(envSessionToken) != "" {
return xerrors.Errorf(
"%s is set. This environment variable takes precedence over any session token stored on disk.\n\n"+
"To log in, unset the environment variable and re-run this command:\n\n"+
"\tunset %s",
envSessionToken, envSessionToken,
)
}
sessionToken, _ := inv.ParsedFlags().GetString(varToken)
if sessionToken == "" {
authURL := *serverURL
@@ -475,7 +489,26 @@ func (r *RootCmd) loginToken() *serpent.Command {
Long: "Print the session token for use in scripts and automation.",
Middleware: serpent.RequireNArgs(0),
Handler: func(inv *serpent.Invocation) error {
tok, err := r.ensureTokenBackend().Read(r.clientURL)
if err := r.ensureClientURL(); err != nil {
return err
}
// When using the file storage, a session token is stored for a single
// deployment URL that the user is logged in to. They keyring can store
// multiple deployment session tokens. Error if the requested URL doesn't
// match the stored config URL when using file storage to avoid returning
// a token for the wrong deployment.
backend := r.ensureTokenBackend()
if _, ok := backend.(*sessionstore.File); ok {
conf := r.createConfig()
storedURL, err := conf.URL().Read()
if err == nil {
storedURL = strings.TrimSpace(storedURL)
if storedURL != r.clientURL.String() {
return xerrors.Errorf("file session token storage only supports one server at a time: requested %s but logged into %s", r.clientURL.String(), storedURL)
}
}
}
tok, err := backend.Read(r.clientURL)
if err != nil {
if xerrors.Is(err, os.ErrNotExist) {
return xerrors.New("no session token found - run 'coder login' first")
+36 -1
View File
@@ -516,6 +516,18 @@ func TestLogin(t *testing.T) {
require.NotEqual(t, client.SessionToken(), sessionFile)
})
t.Run("SessionTokenEnvVar", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
root, _ := clitest.New(t, "login", client.URL.String())
root.Environ.Set("CODER_SESSION_TOKEN", "invalid-token")
err := root.Run()
require.Error(t, err)
require.Contains(t, err.Error(), "CODER_SESSION_TOKEN is set")
require.Contains(t, err.Error(), "unset CODER_SESSION_TOKEN")
})
t.Run("KeepOrganizationContext", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
@@ -558,10 +570,33 @@ func TestLoginToken(t *testing.T) {
t.Run("NoTokenStored", func(t *testing.T) {
t.Parallel()
inv, _ := clitest.New(t, "login", "token")
client := coderdtest.New(t, nil)
inv, _ := clitest.New(t, "login", "token", "--url", client.URL.String())
ctx := testutil.Context(t, testutil.WaitShort)
err := inv.WithContext(ctx).Run()
require.Error(t, err)
require.Contains(t, err.Error(), "no session token found")
})
t.Run("NoURLProvided", func(t *testing.T) {
t.Parallel()
inv, _ := clitest.New(t, "login", "token")
ctx := testutil.Context(t, testutil.WaitShort)
err := inv.WithContext(ctx).Run()
require.Error(t, err)
require.Contains(t, err.Error(), "You are not logged in")
})
t.Run("URLMismatchFileBackend", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
inv, root := clitest.New(t, "login", "token", "--url", "https://other.example.com")
clitest.SetupConfig(t, client, root)
ctx := testutil.Context(t, testutil.WaitShort)
err := inv.WithContext(ctx).Run()
require.Error(t, err)
require.Contains(t, err.Error(), "file session token storage only supports one server")
})
}
+24 -21
View File
@@ -550,30 +550,33 @@ type RootCmd struct {
useKeyringWithGlobalConfig bool
}
// ensureClientURL loads the client URL from the config file if it
// wasn't provided via --url or CODER_URL.
func (r *RootCmd) ensureClientURL() error {
if r.clientURL != nil && r.clientURL.String() != "" {
return nil
}
rawURL, err := r.createConfig().URL().Read()
// If the configuration files are absent, the user is logged out.
if os.IsNotExist(err) {
binPath, err := os.Executable()
if err != nil {
binPath = "coder"
}
return xerrors.Errorf(notLoggedInMessage, binPath)
}
if err != nil {
return err
}
r.clientURL, err = url.Parse(strings.TrimSpace(rawURL))
return err
}
// InitClient creates and configures a new client with authentication, telemetry,
// and version checks.
func (r *RootCmd) InitClient(inv *serpent.Invocation) (*codersdk.Client, error) {
conf := r.createConfig()
var err error
// Read the client URL stored on disk.
if r.clientURL == nil || r.clientURL.String() == "" {
rawURL, err := conf.URL().Read()
// If the configuration files are absent, the user is logged out
if os.IsNotExist(err) {
binPath, err := os.Executable()
if err != nil {
binPath = "coder"
}
return nil, xerrors.Errorf(notLoggedInMessage, binPath)
}
if err != nil {
return nil, err
}
r.clientURL, err = url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return nil, err
}
if err := r.ensureClientURL(); err != nil {
return nil, err
}
if r.token == "" {
tok, err := r.ensureTokenBackend().Read(r.clientURL)
+7 -1
View File
@@ -357,7 +357,13 @@ func (r *RootCmd) ssh() *serpent.Command {
// search domain expansion, which can add 20-30s of
// delay on corporate networks with search domains
// configured.
exists, _ := workspacesdk.ExistsViaCoderConnect(ctx, coderConnectHost+".")
exists, ccErr := workspacesdk.ExistsViaCoderConnect(ctx, coderConnectHost+".")
if ccErr != nil {
logger.Debug(ctx, "failed to check coder connect",
slog.F("hostname", coderConnectHost),
slog.Error(ccErr),
)
}
if exists {
defer cancel()
+11 -3
View File
@@ -52,9 +52,17 @@ func (p *Server) subagentTools(currentChat func() database.Chat) []fantasy.Agent
"(e.g. fixing a specific bug, writing a single module, "+
"running a migration). Do NOT use for simple or quick "+
"operations you can handle directly with execute, "+
"read_file, or write_file. The child agent receives the "+
"same workspace tools but cannot spawn its own subagents. "+
"After spawning, use wait_agent to collect the result.",
"read_file, or write_file - for example, reading a group "+
"of files and outputting them verbatim does not need a "+
"subagent. Reserve subagents for tasks that require "+
"intellectual work such as code analysis, writing new "+
"code, or complex refactoring. Be careful when running "+
"parallel subagents: if two subagents modify the same "+
"files they will conflict with each other, so ensure "+
"parallel subagent tasks are independent. "+
"The child agent receives the same workspace tools but "+
"cannot spawn its own subagents. After spawning, use "+
"wait_agent to collect the result.",
func(ctx context.Context, args spawnAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
if currentChat == nil {
return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil
+59 -2
View File
@@ -55,6 +55,7 @@ const (
defaultChatContextCompressionThreshold = int32(70)
minChatContextCompressionThreshold = int32(0)
maxChatContextCompressionThreshold = int32(100)
maxSystemPromptLenBytes = 131072 // 128 KiB
)
// chatDiffRefreshBackoffSchedule defines the delays between successive
@@ -284,7 +285,7 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
WorkspaceID: workspaceSelection.WorkspaceID,
Title: title,
ModelConfigID: modelConfigID,
SystemPrompt: defaultChatSystemPrompt(),
SystemPrompt: api.resolvedChatSystemPrompt(ctx),
InitialUserContent: contentBlocks,
ContentFileIDs: contentFileIDs,
})
@@ -2262,7 +2263,63 @@ func detectChatFileType(data []byte) string {
return http.DetectContentType(data)
}
func defaultChatSystemPrompt() string {
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatSystemPrompt(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
prompt, err := api.Database.GetChatSystemPrompt(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching chat system prompt.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatSystemPromptResponse{
SystemPrompt: prompt,
})
}
func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req codersdk.UpdateChatSystemPromptRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
trimmedPrompt := strings.TrimSpace(req.SystemPrompt)
// 128 KiB is generous for a system prompt while still
// preventing abuse or accidental pastes of large content.
if len(trimmedPrompt) > maxSystemPromptLenBytes {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "System prompt exceeds maximum length.",
Detail: fmt.Sprintf("Maximum length is %d bytes, got %d.", maxSystemPromptLenBytes, len(trimmedPrompt)),
})
return
}
err := api.Database.UpsertChatSystemPrompt(ctx, trimmedPrompt)
if httpapi.Is404Error(err) { // also catches authz error
httpapi.ResourceNotFound(rw)
return
} else if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating chat system prompt.",
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
func (api *API) resolvedChatSystemPrompt(ctx context.Context) string {
custom, err := api.Database.GetChatSystemPrompt(ctx)
if err != nil {
// Log but don't fail chat creation — fall back to the
// built-in default so the user isn't blocked.
api.Logger.Error(ctx, "failed to fetch custom chat system prompt, using default", slog.Error(err))
return chatd.DefaultSystemPrompt
}
if strings.TrimSpace(custom) != "" {
return custom
}
return chatd.DefaultSystemPrompt
}
+75
View File
@@ -3118,6 +3118,81 @@ func createChatModelConfig(t *testing.T, client *codersdk.Client) codersdk.ChatM
return modelConfig
}
//nolint:tparallel,paralleltest // Subtests share a single coderdtest instance.
func TestChatSystemPrompt(t *testing.T) {
t.Parallel()
adminClient := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient)
memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
t.Run("ReturnsEmptyWhenUnset", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
resp, err := adminClient.GetChatSystemPrompt(ctx)
require.NoError(t, err)
require.Equal(t, "", resp.SystemPrompt)
})
t.Run("AdminCanSet", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
err := adminClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: "You are a helpful coding assistant.",
})
require.NoError(t, err)
resp, err := adminClient.GetChatSystemPrompt(ctx)
require.NoError(t, err)
require.Equal(t, "You are a helpful coding assistant.", resp.SystemPrompt)
})
t.Run("AdminCanUnset", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
// Unset by sending an empty string.
err := adminClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: "",
})
require.NoError(t, err)
resp, err := adminClient.GetChatSystemPrompt(ctx)
require.NoError(t, err)
require.Equal(t, "", resp.SystemPrompt)
})
t.Run("NonAdminFails", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
err := memberClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: "This should fail.",
})
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("UnauthenticatedFails", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
anonClient := codersdk.New(adminClient.URL)
_, err := anonClient.GetChatSystemPrompt(ctx)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode())
})
t.Run("TooLong", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
tooLong := strings.Repeat("a", 131073)
err := adminClient.UpdateChatSystemPrompt(ctx, codersdk.UpdateChatSystemPromptRequest{
SystemPrompt: tooLong,
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "System prompt exceeds maximum length.", sdkErr.Message)
})
}
func requireSDKError(t *testing.T, err error, expectedStatus int) *codersdk.Error {
t.Helper()
+6
View File
@@ -1127,6 +1127,11 @@ func New(options *Options) *API {
r.Post("/", api.postChatFile)
r.Get("/{file}", api.chatFileByID)
})
r.Route("/config", func(r chi.Router) {
r.Get("/system-prompt", api.getChatSystemPrompt)
r.Put("/system-prompt", api.putChatSystemPrompt)
})
// TODO(cian): place under /api/experimental/chats/config
r.Route("/providers", func(r chi.Router) {
r.Get("/", api.listChatProviders)
r.Post("/", api.createChatProvider)
@@ -1135,6 +1140,7 @@ func New(options *Options) *API {
r.Delete("/", api.deleteChatProvider)
})
})
// TODO(cian): place under /api/experimental/chats/config
r.Route("/model-configs", func(r chi.Router) {
r.Get("/", api.listChatModelConfigs)
r.Post("/", api.createChatModelConfig)
+19
View File
@@ -2564,6 +2564,18 @@ func (q *querier) GetChatQueuedMessages(ctx context.Context, chatID uuid.UUID) (
return q.db.GetChatQueuedMessages(ctx, chatID)
}
func (q *querier) GetChatSystemPrompt(ctx context.Context) (string, error) {
// The system prompt is a deployment-wide setting read during chat
// creation by every authenticated user, so no RBAC policy check
// is needed. We still verify that a valid actor exists in the
// context to ensure this is never callable by an unauthenticated
// or system-internal path without an explicit actor.
if _, ok := ActorFromContext(ctx); !ok {
return "", ErrNoActor
}
return q.db.GetChatSystemPrompt(ctx)
}
func (q *querier) GetChatsByOwnerID(ctx context.Context, ownerID database.GetChatsByOwnerIDParams) ([]database.Chat, error) {
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetChatsByOwnerID)(ctx, ownerID)
}
@@ -6536,6 +6548,13 @@ func (q *querier) UpsertChatDiffStatusReference(ctx context.Context, arg databas
return q.db.UpsertChatDiffStatusReference(ctx, arg)
}
func (q *querier) UpsertChatSystemPrompt(ctx context.Context, value string) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
return err
}
return q.db.UpsertChatSystemPrompt(ctx, value)
}
func (q *querier) UpsertConnectionLog(ctx context.Context, arg database.UpsertConnectionLogParams) (database.ConnectionLog, error) {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceConnectionLog); err != nil {
return database.ConnectionLog{}, err
+8
View File
@@ -551,6 +551,10 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().GetChatQueuedMessages(gomock.Any(), chat.ID).Return(qms, nil).AnyTimes()
check.Args(chat.ID).Asserts(chat, policy.ActionRead).Returns(qms)
}))
s.Run("GetChatSystemPrompt", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetChatSystemPrompt(gomock.Any()).Return("prompt", nil).AnyTimes()
check.Args().Asserts()
}))
s.Run("GetEnabledChatModelConfigs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
configA := testutil.Fake(s.T(), faker, database.ChatModelConfig{})
configB := testutil.Fake(s.T(), faker, database.ChatModelConfig{})
@@ -758,6 +762,10 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().UpsertChatDiffStatusReference(gomock.Any(), arg).Return(diffStatus, nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(diffStatus)
}))
s.Run("UpsertChatSystemPrompt", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().UpsertChatSystemPrompt(gomock.Any(), "").Return(nil).AnyTimes()
check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
}))
}
func (s *MethodTestSuite) TestFile() {
+16
View File
@@ -1103,6 +1103,14 @@ func (m queryMetricsStore) GetChatQueuedMessages(ctx context.Context, chatID uui
return r0, r1
}
func (m queryMetricsStore) GetChatSystemPrompt(ctx context.Context) (string, error) {
start := time.Now()
r0, r1 := m.s.GetChatSystemPrompt(ctx)
m.queryLatencies.WithLabelValues("GetChatSystemPrompt").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatSystemPrompt").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatsByOwnerID(ctx context.Context, ownerID database.GetChatsByOwnerIDParams) ([]database.Chat, error) {
start := time.Now()
r0, r1 := m.s.GetChatsByOwnerID(ctx, ownerID)
@@ -4526,6 +4534,14 @@ func (m queryMetricsStore) UpsertChatDiffStatusReference(ctx context.Context, ar
return r0, r1
}
func (m queryMetricsStore) UpsertChatSystemPrompt(ctx context.Context, value string) error {
start := time.Now()
r0 := m.s.UpsertChatSystemPrompt(ctx, value)
m.queryLatencies.WithLabelValues("UpsertChatSystemPrompt").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatSystemPrompt").Inc()
return r0
}
func (m queryMetricsStore) UpsertConnectionLog(ctx context.Context, arg database.UpsertConnectionLogParams) (database.ConnectionLog, error) {
start := time.Now()
r0, r1 := m.s.UpsertConnectionLog(ctx, arg)
+29
View File
@@ -2017,6 +2017,21 @@ func (mr *MockStoreMockRecorder) GetChatQueuedMessages(ctx, chatID any) *gomock.
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatQueuedMessages", reflect.TypeOf((*MockStore)(nil).GetChatQueuedMessages), ctx, chatID)
}
// GetChatSystemPrompt mocks base method.
func (m *MockStore) GetChatSystemPrompt(ctx context.Context) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatSystemPrompt", ctx)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatSystemPrompt indicates an expected call of GetChatSystemPrompt.
func (mr *MockStoreMockRecorder) GetChatSystemPrompt(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatSystemPrompt", reflect.TypeOf((*MockStore)(nil).GetChatSystemPrompt), ctx)
}
// GetChatsByOwnerID mocks base method.
func (m *MockStore) GetChatsByOwnerID(ctx context.Context, arg database.GetChatsByOwnerIDParams) ([]database.Chat, error) {
m.ctrl.T.Helper()
@@ -8463,6 +8478,20 @@ func (mr *MockStoreMockRecorder) UpsertChatDiffStatusReference(ctx, arg any) *go
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatDiffStatusReference", reflect.TypeOf((*MockStore)(nil).UpsertChatDiffStatusReference), ctx, arg)
}
// UpsertChatSystemPrompt mocks base method.
func (m *MockStore) UpsertChatSystemPrompt(ctx context.Context, value string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpsertChatSystemPrompt", ctx, value)
ret0, _ := ret[0].(error)
return ret0
}
// UpsertChatSystemPrompt indicates an expected call of UpsertChatSystemPrompt.
func (mr *MockStoreMockRecorder) UpsertChatSystemPrompt(ctx, value any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatSystemPrompt", reflect.TypeOf((*MockStore)(nil).UpsertChatSystemPrompt), ctx, value)
}
// UpsertConnectionLog mocks base method.
func (m *MockStore) UpsertConnectionLog(ctx context.Context, arg database.UpsertConnectionLogParams) (database.ConnectionLog, error) {
m.ctrl.T.Helper()
+2
View File
@@ -230,6 +230,7 @@ type sqlcQuerier interface {
GetChatProviderByProvider(ctx context.Context, provider string) (ChatProvider, error)
GetChatProviders(ctx context.Context) ([]ChatProvider, error)
GetChatQueuedMessages(ctx context.Context, chatID uuid.UUID) ([]ChatQueuedMessage, error)
GetChatSystemPrompt(ctx context.Context) (string, error)
GetChatsByOwnerID(ctx context.Context, arg GetChatsByOwnerIDParams) ([]Chat, error)
GetConnectionLogsOffset(ctx context.Context, arg GetConnectionLogsOffsetParams) ([]GetConnectionLogsOffsetRow, error)
GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error)
@@ -840,6 +841,7 @@ type sqlcQuerier interface {
UpsertBoundaryUsageStats(ctx context.Context, arg UpsertBoundaryUsageStatsParams) (bool, error)
UpsertChatDiffStatus(ctx context.Context, arg UpsertChatDiffStatusParams) (ChatDiffStatus, error)
UpsertChatDiffStatusReference(ctx context.Context, arg UpsertChatDiffStatusReferenceParams) (ChatDiffStatus, error)
UpsertChatSystemPrompt(ctx context.Context, value string) error
UpsertConnectionLog(ctx context.Context, arg UpsertConnectionLogParams) (ConnectionLog, error)
UpsertCoordinatorResumeTokenSigningKey(ctx context.Context, value string) error
// The default proxy is implied and not actually stored in the database.
+22
View File
@@ -14475,6 +14475,18 @@ func (q *sqlQuerier) GetApplicationName(ctx context.Context) (string, error) {
return value, err
}
const getChatSystemPrompt = `-- name: GetChatSystemPrompt :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_system_prompt'), '') :: text AS chat_system_prompt
`
func (q *sqlQuerier) GetChatSystemPrompt(ctx context.Context) (string, error) {
row := q.db.QueryRowContext(ctx, getChatSystemPrompt)
var chat_system_prompt string
err := row.Scan(&chat_system_prompt)
return chat_system_prompt, err
}
const getCoordinatorResumeTokenSigningKey = `-- name: GetCoordinatorResumeTokenSigningKey :one
SELECT value FROM site_configs WHERE key = 'coordinator_resume_token_signing_key'
`
@@ -14689,6 +14701,16 @@ func (q *sqlQuerier) UpsertApplicationName(ctx context.Context, value string) er
return err
}
const upsertChatSystemPrompt = `-- name: UpsertChatSystemPrompt :exec
INSERT INTO site_configs (key, value) VALUES ('agents_chat_system_prompt', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_system_prompt'
`
func (q *sqlQuerier) UpsertChatSystemPrompt(ctx context.Context, value string) error {
_, err := q.db.ExecContext(ctx, upsertChatSystemPrompt, value)
return err
}
const upsertCoordinatorResumeTokenSigningKey = `-- name: UpsertCoordinatorResumeTokenSigningKey :exec
INSERT INTO site_configs (key, value) VALUES ('coordinator_resume_token_signing_key', $1)
ON CONFLICT (key) DO UPDATE set value = $1 WHERE site_configs.key = 'coordinator_resume_token_signing_key'
+8
View File
@@ -153,3 +153,11 @@ DO UPDATE SET value = EXCLUDED.value WHERE site_configs.key = EXCLUDED.key;
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'webpush_vapid_public_key'), '') :: text AS vapid_public_key,
COALESCE((SELECT value FROM site_configs WHERE key = 'webpush_vapid_private_key'), '') :: text AS vapid_private_key;
-- name: GetChatSystemPrompt :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_system_prompt'), '') :: text AS chat_system_prompt;
-- name: UpsertChatSystemPrompt :exec
INSERT INTO site_configs (key, value) VALUES ('agents_chat_system_prompt', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_system_prompt';
+37
View File
@@ -202,6 +202,16 @@ type ChatModelsResponse struct {
Providers []ChatModelProvider `json:"providers"`
}
// ChatSystemPromptResponse is the response for getting the chat system prompt.
type ChatSystemPromptResponse struct {
SystemPrompt string `json:"system_prompt"`
}
// UpdateChatSystemPromptRequest is the request to update the chat system prompt.
type UpdateChatSystemPromptRequest struct {
SystemPrompt string `json:"system_prompt"`
}
// ChatProviderConfigSource describes how a provider entry is sourced.
type ChatProviderConfigSource string
@@ -681,6 +691,33 @@ func (c *Client) DeleteChatModelConfig(ctx context.Context, modelConfigID uuid.U
return nil
}
// GetChatSystemPrompt returns the deployment-wide chat system prompt.
func (c *Client) GetChatSystemPrompt(ctx context.Context) (ChatSystemPromptResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/config/system-prompt", nil)
if err != nil {
return ChatSystemPromptResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ChatSystemPromptResponse{}, ReadBodyAsError(res)
}
var resp ChatSystemPromptResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// UpdateChatSystemPrompt updates the deployment-wide chat system prompt.
func (c *Client) UpdateChatSystemPrompt(ctx context.Context, req UpdateChatSystemPromptRequest) error {
res, err := c.Request(ctx, http.MethodPut, "/api/experimental/chats/config/system-prompt", req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
// CreateChat creates a new chat.
func (c *Client) CreateChat(ctx context.Context, req CreateChatRequest) (Chat, error) {
res, err := c.Request(ctx, http.MethodPost, "/api/experimental/chats", req)
+2
View File
@@ -622,3 +622,5 @@ replace github.com/anthropics/anthropic-sdk-go v1.19.0 => github.com/dannykoppin
// https://github.com/openai/openai-go/pull/602
replace github.com/openai/openai-go/v3 => github.com/SasSwart/openai-go/v3 v3.0.0-20260204134041-fb987b42a728
replace github.com/hashicorp/go-reap => github.com/coder/go-reap v0.0.0-20260310125653-03afbd91857d
+2 -2
View File
@@ -327,6 +327,8 @@ github.com/coder/flog v1.1.0 h1:kbAes1ai8fIS5OeV+QAnKBQE22ty1jRF/mcAwHpLBa4=
github.com/coder/flog v1.1.0/go.mod h1:UQlQvrkJBvnRGo69Le8E24Tcl5SJleAAR7gYEHzAmdQ=
github.com/coder/go-httpstat v0.0.0-20230801153223-321c88088322 h1:m0lPZjlQ7vdVpRBPKfYIFlmgevoTkBxB10wv6l2gOaU=
github.com/coder/go-httpstat v0.0.0-20230801153223-321c88088322/go.mod h1:rOLFDDVKVFiDqZFXoteXc97YXx7kFi9kYqR+2ETPkLQ=
github.com/coder/go-reap v0.0.0-20260310125653-03afbd91857d h1:RXVQcJgSaZcuz3/dimDRr3j/7lszysBT8VphrzCYjG0=
github.com/coder/go-reap v0.0.0-20260310125653-03afbd91857d/go.mod h1:TGNW2TJmp3wSQ8K4E1mrjS1d70xPL40mAFttt8UAyA0=
github.com/coder/go-scim/pkg/v2 v2.0.0-20230221055123-1d63c1222136 h1:0RgB61LcNs24WOxc3PBvygSNTQurm0PYPujJjLLOzs0=
github.com/coder/go-scim/pkg/v2 v2.0.0-20230221055123-1d63c1222136/go.mod h1:VkD1P761nykiq75dz+4iFqIQIZka189tx1BQLOp0Skc=
github.com/coder/guts v1.6.1 h1:bMVBtDNP/1gW58NFRBdzStAQzXlveMrLAnORpwE9tYo=
@@ -682,8 +684,6 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA=
github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8=
github.com/hashicorp/go-reap v0.0.0-20170704170343-bf58d8a43e7b h1:3GrpnZQBxcMj1gCXQLelfjCT1D5MPGTuGMKHVzSIH6A=
github.com/hashicorp/go-reap v0.0.0-20170704170343-bf58d8a43e7b/go.mod h1:qIFzeFcJU3OIFk/7JreWXcUjFmcCaeHTH9KoNyHYVCs=
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
github.com/hashicorp/go-terraform-address v0.0.0-20240523040243-ccea9d309e0c h1:5v6L/m/HcAZYbrLGYBpPkcCVtDWwIgFxq2+FUmfPxPk=
+14
View File
@@ -3052,6 +3052,20 @@ class ApiMethods {
return response.data;
};
getChatSystemPrompt =
async (): Promise<TypesGen.ChatSystemPromptResponse> => {
const response = await this.axios.get<TypesGen.ChatSystemPromptResponse>(
"/api/experimental/chats/config/system-prompt",
);
return response.data;
};
updateChatSystemPrompt = async (
req: TypesGen.UpdateChatSystemPromptRequest,
): Promise<void> => {
await this.axios.put("/api/experimental/chats/config/system-prompt", req);
};
getChatProviderConfigs = async (): Promise<TypesGen.ChatProviderConfig[]> => {
const response = await this.axios.get<TypesGen.ChatProviderConfig[]>(
chatProviderConfigsPath,
+16
View File
@@ -196,6 +196,22 @@ export const chatDiffContents = (chatId: string) => ({
queryFn: () => API.getChatDiffContents(chatId),
});
const chatSystemPromptKey = ["chat-system-prompt"] as const;
export const chatSystemPrompt = () => ({
queryKey: chatSystemPromptKey,
queryFn: () => API.getChatSystemPrompt(),
});
export const updateChatSystemPrompt = (queryClient: QueryClient) => ({
mutationFn: API.updateChatSystemPrompt,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: chatSystemPromptKey,
});
},
});
export const chatModelsKey = ["chat-models"] as const;
export const chatModels = () => ({
+16
View File
@@ -1611,6 +1611,14 @@ export interface ChatStreamStatus {
readonly status: ChatStatus;
}
// From codersdk/chats.go
/**
* ChatSystemPromptResponse is the response for getting the chat system prompt.
*/
export interface ChatSystemPromptResponse {
readonly system_prompt: string;
}
// From codersdk/chats.go
/**
* ChatWithMessages is a chat along with its messages.
@@ -6303,6 +6311,14 @@ export interface UpdateChatRequest {
readonly title: string;
}
// From codersdk/chats.go
/**
* UpdateChatSystemPromptRequest is the request to update the chat system prompt.
*/
export interface UpdateChatSystemPromptRequest {
readonly system_prompt: string;
}
// From codersdk/updatecheck.go
/**
* UpdateCheckResponse contains information on the latest release of Coder.
@@ -137,6 +137,28 @@ export const LoadingDisablesSend: Story = {
},
};
export const Streaming: Story = {
args: {
isStreaming: true,
onInterrupt: fn(),
isInterruptPending: false,
initialValue: "",
onAttach: fn(),
onRemoveAttachment: fn(),
},
};
export const StreamingInterruptPending: Story = {
args: {
isStreaming: true,
onInterrupt: fn(),
isInterruptPending: true,
initialValue: "",
onAttach: fn(),
onRemoveAttachment: fn(),
},
};
const longContent = Array.from(
{ length: 60 },
(_, i) =>
+5 -5
View File
@@ -640,12 +640,12 @@ export const AgentChatInput = memo<AgentChatInputProps>(
type="button"
variant="outline"
size="icon"
className="size-7 shrink-0 rounded-full [&>svg]:p-0"
className="size-7 shrink-0 rounded-full [&>svg]:!size-icon-sm [&>svg]:p-0"
onClick={() => fileInputRef.current?.click()}
disabled={isDisabled}
aria-label="Attach files"
>
<ImageIcon className="h-4 w-4" />
<ImageIcon />
</Button>
</>
)}
@@ -653,11 +653,11 @@ export const AgentChatInput = memo<AgentChatInputProps>(
<Button
size="icon"
variant="default"
className="size-7 rounded-full transition-colors [&>svg]:p-0"
className="size-7 rounded-full transition-colors [&>svg]:!size-3 [&>svg]:p-0"
onClick={onInterrupt}
disabled={isInterruptPending}
>
<Square className="h-3 w-3 fill-current" />
<Square className="fill-current" />
<span className="sr-only">Stop</span>
</Button>
)}
@@ -665,7 +665,7 @@ export const AgentChatInput = memo<AgentChatInputProps>(
<Button
size="icon"
variant="default"
className="size-7 rounded-full transition-colors [&>svg]:!size-6 flex items-center justify-center"
className="size-7 rounded-full transition-colors [&>svg]:!size-5 [&>svg]:p-0"
onClick={handleSubmit}
disabled={!canSend}
>
@@ -11,7 +11,7 @@ import {
waitFor,
within,
} from "storybook/test";
import { AgentsEmptyState } from "./AgentsPage";
import { AgentCreateForm } from "./AgentsPage";
const modelOptions = [
{
@@ -22,11 +22,9 @@ const modelOptions = [
},
] as const;
const behaviorStorageKey = "agents.system-prompt";
const meta: Meta<typeof AgentsEmptyState> = {
title: "pages/AgentsPage/AgentsEmptyState",
component: AgentsEmptyState,
const meta: Meta<typeof AgentCreateForm> = {
title: "pages/AgentsPage/AgentCreateForm",
component: AgentCreateForm,
decorators: [withDashboardProvider],
args: {
onCreateChat: fn(),
@@ -49,11 +47,15 @@ const meta: Meta<typeof AgentsEmptyState> = {
workspaces: [],
count: 0,
});
spyOn(API, "getChatSystemPrompt").mockResolvedValue({
system_prompt: "",
});
spyOn(API, "updateChatSystemPrompt").mockResolvedValue();
},
};
export default meta;
type Story = StoryObj<typeof AgentsEmptyState>;
type Story = StoryObj<typeof AgentCreateForm>;
export const Default: Story = {};
@@ -186,9 +188,9 @@ export const SavesBehaviorPromptAndRestores: Story = {
await userEvent.click(within(dialog).getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(localStorage.getItem(behaviorStorageKey)).toBe(
"You are a focused coding assistant.",
);
expect(API.updateChatSystemPrompt).toHaveBeenCalledWith({
system_prompt: "You are a focused coding assistant.",
});
});
},
};
+81 -308
View File
@@ -15,8 +15,6 @@ import { deploymentSSHConfig } from "api/queries/deployment";
import { workspaceById, workspaceByIdKey } from "api/queries/workspaces";
import type * as TypesGen from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { Skeleton } from "components/Skeleton/Skeleton";
import { ArchiveIcon } from "lucide-react";
import {
getTerminalHref,
getVSCodeHref,
@@ -34,7 +32,6 @@ import {
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useNavigate, useOutletContext, useParams } from "react-router";
import { toast } from "sonner";
import { cn } from "utils/cn";
import { pageTitle } from "utils/page";
import {
AgentChatInput,
@@ -66,32 +63,23 @@ import {
parseMessagesWithMergedTools,
} from "./AgentDetail/messageParsing";
import { buildStreamTools } from "./AgentDetail/streamState";
import { AgentDetailTopBar } from "./AgentDetail/TopBar";
import { useMessageWindow } from "./AgentDetail/useMessageWindow";
import { useWorkspaceCreationWatcher } from "./AgentDetail/useWorkspaceCreationWatcher";
import {
AgentDetailLoadingView,
AgentDetailNotFoundView,
AgentDetailView,
} from "./AgentDetailView";
import type { AgentsOutletContext } from "./AgentsPage";
import { GitPanel } from "./GitPanel";
import {
getModelCatalogStatusMessage,
getModelOptionsFromCatalog,
getModelSelectorPlaceholder,
hasConfiguredModelsInCatalog,
} from "./modelOptions";
import { RightPanel } from "./RightPanel";
import { type SidebarTab, SidebarTabView } from "./SidebarTabView";
import { useFileAttachments } from "./useFileAttachments";
import { useGitWatcher } from "./useGitWatcher";
const noopSetChatErrorReason: AgentsOutletContext["setChatErrorReason"] =
() => {};
const noopClearChatErrorReason: AgentsOutletContext["clearChatErrorReason"] =
() => {};
const noopRequestArchiveAgent: AgentsOutletContext["requestArchiveAgent"] =
() => {};
const noopRequestArchiveAndDeleteWorkspace: AgentsOutletContext["requestArchiveAndDeleteWorkspace"] =
() => {};
const noopRequestUnarchiveAgent: AgentsOutletContext["requestUnarchiveAgent"] =
() => {};
const lastModelConfigIDStorageKey = "agents.last-model-config-id";
/** @internal Exported for testing. */
export const draftInputStorageKeyPrefix = "agents.draft-input.";
@@ -114,7 +102,7 @@ interface AgentDetailTimelineProps {
savingMessageId?: number | null;
}
const AgentDetailTimeline: FC<AgentDetailTimelineProps> = ({
export const AgentDetailTimeline: FC<AgentDetailTimelineProps> = ({
store,
chatID,
persistedErrorReason,
@@ -229,7 +217,7 @@ interface AgentDetailInputProps {
}[];
}
const AgentDetailInput: FC<AgentDetailInputProps> = ({
export const AgentDetailInput: FC<AgentDetailInputProps> = ({
store,
compressionThreshold,
onSend,
@@ -560,36 +548,22 @@ export function useConversationEditingState(deps: {
const AgentDetail: FC = () => {
const navigate = useNavigate();
const { agentId } = useParams<{ agentId: string }>();
const outletContext = useOutletContext<AgentsOutletContext | undefined>();
const outletContext = useOutletContext<AgentsOutletContext>();
const queryClient = useQueryClient();
const [selectedModel, setSelectedModel] = useState("");
const [showSidebarPanel, setShowSidebarPanel] = useState(false);
const [isRightPanelExpanded, setIsRightPanelExpanded] = useState(false);
// Tracks the live visual expanded state during drag so sibling
// content hides/shows in real-time rather than on pointer-up.
// Null means "no drag override, use isRightPanelExpanded".
const [dragVisualExpanded, setDragVisualExpanded] = useState<boolean | null>(
null,
);
const visualExpanded = dragVisualExpanded ?? isRightPanelExpanded;
const [pendingEditMessageId, setPendingEditMessageId] = useState<
number | null
>(null);
const chatErrorReasons = outletContext?.chatErrorReasons ?? {};
const setChatErrorReason =
outletContext?.setChatErrorReason ?? noopSetChatErrorReason;
const clearChatErrorReason =
outletContext?.clearChatErrorReason ?? noopClearChatErrorReason;
const requestArchiveAgent =
outletContext?.requestArchiveAgent ?? noopRequestArchiveAgent;
const requestArchiveAndDeleteWorkspace =
outletContext?.requestArchiveAndDeleteWorkspace ??
noopRequestArchiveAndDeleteWorkspace;
const requestUnarchiveAgent =
outletContext?.requestUnarchiveAgent ?? noopRequestUnarchiveAgent;
const isSidebarCollapsed = outletContext?.isSidebarCollapsed ?? false;
const onToggleSidebarCollapsed =
outletContext?.onToggleSidebarCollapsed ?? (() => {});
const {
chatErrorReasons,
setChatErrorReason,
clearChatErrorReason,
requestArchiveAgent,
requestArchiveAndDeleteWorkspace,
requestUnarchiveAgent,
isSidebarCollapsed,
onToggleSidebarCollapsed,
} = outletContext;
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const chatInputRef = useRef<ChatMessageInputRef | null>(null);
const inputValueRef = useRef("");
@@ -632,7 +606,6 @@ const AgentDetail: FC = () => {
const chatModelsQuery = useQuery(chatModels());
const chatModelConfigsQuery = useQuery(chatModelConfigs());
const sshConfigQuery = useQuery(deploymentSSHConfig());
const hasDiffStatus = Boolean(diffStatusQuery.data?.url);
const workspace = workspaceQuery.data;
const workspaceAgent = getWorkspaceAgent(workspace, undefined);
const chatData = chatQuery.data;
@@ -642,16 +615,6 @@ const AgentDetail: FC = () => {
const chatQueuedMessages = chatData?.queued_messages;
const chatLastModelConfigID = chatRecord?.last_model_config_id;
// Auto-open the diff panel when diff status first appears.
// See: https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
const [prevHasDiffStatus, setPrevHasDiffStatus] = useState(false);
if (hasDiffStatus !== prevHasDiffStatus) {
setPrevHasDiffStatus(hasDiffStatus);
if (hasDiffStatus && !window.matchMedia("(max-width: 767px)").matches) {
setShowSidebarPanel(true);
}
}
const modelOptions = useMemo(
() =>
getModelOptionsFromCatalog(
@@ -739,17 +702,6 @@ const AgentDetail: FC = () => {
chatInputRef.current?.focus();
}, []);
// Auto-open sidebar when git watcher receives its first non-empty
// repositories update.
const [prevHasGitRepos, setPrevHasGitRepos] = useState(false);
const hasGitRepos = gitWatcher.repositories.size > 0;
if (hasGitRepos !== prevHasGitRepos) {
setPrevHasGitRepos(hasGitRepos);
if (hasGitRepos && !window.matchMedia("(max-width: 767px)").matches) {
setShowSidebarPanel(true);
}
}
// Extract PR number from diff status URL.
const prMatch = diffStatusQuery.data?.url?.match(/\/pull\/(\d+)/)?.[1];
const prNumber = prMatch ? Number(prMatch) : undefined;
@@ -996,7 +948,6 @@ const AgentDetail: FC = () => {
workspace && workspaceAgent && sshConfigQuery.data?.hostname_suffix
? `ssh ${workspaceAgent.name}.${workspace.name}.${workspace.owner_name}.${sshConfigQuery.data.hostname_suffix}`
: undefined;
const shouldShowSidebar = showSidebarPanel;
const generateKeyMutation = useMutation({
mutationFn: () => API.getApiKey(),
@@ -1064,255 +1015,77 @@ const AgentDetail: FC = () => {
if (chatQuery.isLoading) {
return (
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col">
{titleElement}
<AgentDetailTopBar
panel={{
showSidebarPanel: false,
onToggleSidebar: () => {},
}}
workspace={{
canOpenEditors: false,
canOpenWorkspace: false,
onOpenInEditor: () => {},
onViewWorkspace: () => {},
onOpenTerminal: () => {},
sshCommand: undefined,
}}
onOpenParentChat={() => {}}
onArchiveAgent={() => {}}
onUnarchiveAgent={() => {}}
onArchiveAndDeleteWorkspace={() => {}}
hasWorkspace={false}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
<div className="flex min-h-0 flex-1 flex-col-reverse overflow-hidden">
<div className="px-4">
<div className="mx-auto w-full max-w-3xl py-6">
<div className="flex flex-col gap-3">
{/* User message bubble (right-aligned) */}
<div className="flex w-full justify-end">
<Skeleton className="h-10 w-2/3 rounded-lg" />
</div>
{/* Assistant response lines (left-aligned) */}
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</div>
{/* Second user message bubble */}
<div className="mt-3 flex w-full justify-end">
<Skeleton className="h-10 w-1/2 rounded-lg" />
</div>
{/* Second assistant response */}
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/5" />
</div>{" "}
</div>
</div>
</div>
</div>
<div className="shrink-0 px-4">
<AgentChatInput
onSend={() => {}}
initialValue=""
isDisabled={isInputDisabled}
isLoading={false}
selectedModel={effectiveSelectedModel}
onModelChange={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
hasModelOptions={hasModelOptions}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
/>
</div>
</div>
<AgentDetailLoadingView
titleElement={titleElement}
isInputDisabled={isInputDisabled}
effectiveSelectedModel={effectiveSelectedModel}
setSelectedModel={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
hasModelOptions={hasModelOptions}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
);
}
if (!chatQuery.data || !agentId) {
return (
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col">
{titleElement}
<AgentDetailTopBar
panel={{
showSidebarPanel: false,
onToggleSidebar: () => {},
}}
workspace={{
canOpenEditors: false,
canOpenWorkspace: false,
onOpenInEditor: () => {},
onViewWorkspace: () => {},
onOpenTerminal: () => {},
sshCommand: undefined,
}}
onOpenParentChat={() => {}}
onArchiveAgent={() => {}}
onUnarchiveAgent={() => {}}
onArchiveAndDeleteWorkspace={() => {}}
hasWorkspace={false}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
<div className="flex flex-1 items-center justify-center text-content-secondary">
Chat not found
</div>{" "}
</div>
<AgentDetailNotFoundView
titleElement={titleElement}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
);
}
return (
<div
className={cn(
"relative flex min-h-0 min-w-0 flex-1",
shouldShowSidebar && !visualExpanded && "flex-row",
)}
>
{titleElement}
<div
className={cn(
"relative flex min-h-0 min-w-0 flex-1 flex-col",
visualExpanded && "hidden",
shouldShowSidebar && "max-md:hidden",
)}
>
<div className="relative z-10 shrink-0 overflow-visible">
<AgentDetailTopBar
chatTitle={chatTitle}
parentChat={parentChat}
onOpenParentChat={(chatId) => navigate(`/agents/${chatId}`)}
panel={{
showSidebarPanel,
onToggleSidebar: () => setShowSidebarPanel((prev) => !prev),
}}
workspace={{
canOpenEditors,
canOpenWorkspace,
onOpenInEditor: handleOpenInEditor,
onViewWorkspace: handleViewWorkspace,
onOpenTerminal: handleOpenTerminal,
sshCommand,
}}
onArchiveAgent={handleArchiveAgentAction}
onUnarchiveAgent={handleUnarchiveAgentAction}
onArchiveAndDeleteWorkspace={handleArchiveAndDeleteWorkspaceAction}
hasWorkspace={Boolean(workspaceId)}
isArchived={isArchived}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
{isArchived && (
<div className="flex shrink-0 items-center gap-2 border-b border-border-default bg-surface-secondary px-4 py-2 text-xs text-content-secondary">
<ArchiveIcon className="h-4 w-4 shrink-0" />
This agent has been archived and is read-only.
</div>
)}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-full z-10 h-6 bg-surface-primary"
style={{
maskImage:
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
}}
/>
</div>
<div
ref={scrollContainerRef}
className="flex min-h-0 flex-1 flex-col-reverse overflow-y-auto [scrollbar-gutter:stable] [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]"
>
<div className="px-4">
<AgentDetailTimeline
store={store}
chatID={agentId}
persistedErrorReason={
chatErrorReasons[agentId] || chatRecord?.last_error || undefined
}
onEditUserMessage={editing.handleEditUserMessage}
editingMessageId={editing.editingMessageId}
savingMessageId={pendingEditMessageId}
/>
</div>
</div>
<div className="shrink-0 overflow-y-auto px-4 [scrollbar-gutter:stable] [scrollbar-width:thin]">
<AgentDetailInput
store={store}
compressionThreshold={compressionThreshold}
onSend={editing.handleSendFromInput}
onDeleteQueuedMessage={handleDeleteQueuedMessage}
onPromoteQueuedMessage={handlePromoteQueuedMessage}
onInterrupt={handleInterrupt}
isInputDisabled={isInputDisabled}
isSendPending={isSubmissionPending}
isInterruptPending={interruptMutation.isPending}
hasModelOptions={hasModelOptions}
selectedModel={effectiveSelectedModel}
onModelChange={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
inputRef={editing.chatInputRef}
initialValue={editing.editorInitialValue}
onContentChange={editing.handleContentChange}
editingQueuedMessageID={editing.editingQueuedMessageID}
onStartQueueEdit={editing.handleStartQueueEdit}
onCancelQueueEdit={editing.handleCancelQueueEdit}
isEditingHistoryMessage={editing.editingMessageId !== null}
onCancelHistoryEdit={editing.handleCancelHistoryEdit}
editingFileBlocks={editing.editingFileBlocks}
/>
</div>
</div>
<RightPanel
isOpen={shouldShowSidebar}
isExpanded={isRightPanelExpanded}
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
onClose={() => setShowSidebarPanel(false)}
onVisualExpandedChange={setDragVisualExpanded}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
>
<SidebarTabView
tabs={
[
(hasDiffStatus || hasGitRepos) && {
id: "git",
label: "Git",
content: (
<GitPanel
prTab={
prNumber && agentId
? { prNumber, chatId: agentId }
: undefined
}
repositories={gitWatcher.repositories}
onRefresh={gitWatcher.refresh}
onCommit={handleCommit}
isExpanded={visualExpanded}
remoteDiffStats={diffStatusQuery.data}
chatInputRef={editing.chatInputRef}
/>
),
},
].filter(Boolean) as SidebarTab[]
}
onClose={() => setShowSidebarPanel(false)}
isExpanded={visualExpanded}
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
chatTitle={chatTitle}
/>
</RightPanel>{" "}
</div>
<AgentDetailView
agentId={agentId}
chatTitle={chatTitle}
parentChat={parentChat}
chatErrorReasons={chatErrorReasons}
chatRecord={chatRecord}
isArchived={isArchived}
hasWorkspace={Boolean(workspaceId)}
store={store}
editing={editing}
pendingEditMessageId={pendingEditMessageId}
effectiveSelectedModel={effectiveSelectedModel}
setSelectedModel={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
hasModelOptions={hasModelOptions}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
compressionThreshold={compressionThreshold}
isInputDisabled={isInputDisabled}
isSubmissionPending={isSubmissionPending}
isInterruptPending={interruptMutation.isPending}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
prNumber={prNumber}
diffStatusData={diffStatusQuery.data}
gitWatcher={gitWatcher}
canOpenEditors={canOpenEditors}
canOpenWorkspace={canOpenWorkspace}
sshCommand={sshCommand}
handleOpenInEditor={handleOpenInEditor}
handleViewWorkspace={handleViewWorkspace}
handleOpenTerminal={handleOpenTerminal}
handleCommit={handleCommit}
onNavigateToChat={(chatId) => navigate(`/agents/${chatId}`)}
handleInterrupt={handleInterrupt}
handleDeleteQueuedMessage={handleDeleteQueuedMessage}
handlePromoteQueuedMessage={handlePromoteQueuedMessage}
handleArchiveAgentAction={handleArchiveAgentAction}
handleUnarchiveAgentAction={handleUnarchiveAgentAction}
handleArchiveAndDeleteWorkspaceAction={
handleArchiveAndDeleteWorkspaceAction
}
scrollContainerRef={scrollContainerRef}
/>
);
};
@@ -0,0 +1,318 @@
import { MockUserOwner } from "testHelpers/entities";
import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ChatDiffStatusResponse } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { fn } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { createChatStore } from "./AgentDetail/ChatContext";
import {
AgentDetailLoadingView,
AgentDetailNotFoundView,
AgentDetailView,
} from "./AgentDetailView";
// ---------------------------------------------------------------------------
// Shared constants & helpers
// ---------------------------------------------------------------------------
const AGENT_ID = "agent-detail-view-1";
const defaultModelOptions: ModelSelectorOption[] = [
{
id: "openai:gpt-4o",
provider: "openai",
model: "gpt-4o",
displayName: "GPT-4o",
},
];
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const buildChat = (overrides: Partial<TypesGen.Chat> = {}): TypesGen.Chat => ({
id: AGENT_ID,
owner_id: "owner-1",
title: "Help me refactor",
status: "completed",
last_model_config_id: "model-config-1",
created_at: oneWeekAgo,
updated_at: oneWeekAgo,
archived: false,
last_error: null,
...overrides,
});
const defaultEditing = {
chatInputRef: { current: null },
editorInitialValue: "",
editingMessageId: null,
editingFileBlocks: [] as readonly {
mediaType: string;
data?: string;
fileId?: string;
}[],
handleEditUserMessage: fn(),
handleCancelHistoryEdit: fn(),
editingQueuedMessageID: null,
handleStartQueueEdit: fn(),
handleCancelQueueEdit: fn(),
handleSendFromInput: fn(),
handleContentChange: fn(),
};
const defaultGitWatcher: {
repositories: ReadonlyMap<string, TypesGen.WorkspaceAgentRepoChanges>;
refresh: () => void;
} = {
repositories: new Map(),
refresh: fn(),
};
const agentsRouting = [
{ path: "/agents/:agentId", useStoryElement: true },
{ path: "/agents", useStoryElement: true },
] satisfies [
{ path: string; useStoryElement: boolean },
...{ path: string; useStoryElement: boolean }[],
];
// ---------------------------------------------------------------------------
// Meta
// ---------------------------------------------------------------------------
const meta: Meta<typeof AgentDetailView> = {
title: "pages/AgentsPage/AgentDetailView",
component: AgentDetailView,
decorators: [withAuthProvider, withDashboardProvider],
parameters: {
layout: "fullscreen",
user: MockUserOwner,
reactRouter: reactRouterParameters({
location: {
path: `/agents/${AGENT_ID}`,
pathParams: { agentId: AGENT_ID },
},
routing: agentsRouting,
}),
},
args: {
agentId: AGENT_ID,
chatTitle: "Help me refactor",
parentChat: undefined,
chatErrorReasons: {},
chatRecord: buildChat(),
isArchived: false,
hasWorkspace: true,
store: createChatStore(),
editing: defaultEditing,
pendingEditMessageId: null,
effectiveSelectedModel: "openai:gpt-4o",
setSelectedModel: fn(),
modelOptions: defaultModelOptions,
modelSelectorPlaceholder: "Select a model",
hasModelOptions: true,
inputStatusText: null,
modelCatalogStatusMessage: null,
compressionThreshold: undefined,
isInputDisabled: false,
isSubmissionPending: false,
isInterruptPending: false,
isSidebarCollapsed: false,
onToggleSidebarCollapsed: fn(),
prNumber: undefined,
diffStatusData: undefined,
gitWatcher: defaultGitWatcher,
canOpenEditors: false,
canOpenWorkspace: false,
sshCommand: undefined,
handleOpenInEditor: fn(),
handleViewWorkspace: fn(),
handleOpenTerminal: fn(),
handleCommit: fn(),
onNavigateToChat: fn(),
handleInterrupt: fn(),
handleDeleteQueuedMessage: fn(),
handlePromoteQueuedMessage: fn(),
handleArchiveAgentAction: fn(),
handleUnarchiveAgentAction: fn(),
handleArchiveAndDeleteWorkspaceAction: fn(),
scrollContainerRef: { current: null },
},
};
export default meta;
type Story = StoryObj<typeof AgentDetailView>;
// ---------------------------------------------------------------------------
// AgentDetailView stories
// ---------------------------------------------------------------------------
/** Basic conversation view with a chat title, workspace, and no archive. */
export const Default: Story = {};
/** Archived agent displays the read-only banner below the top bar. */
export const Archived: Story = {
args: {
isArchived: true,
chatRecord: buildChat({ archived: true }),
isInputDisabled: true,
},
};
/** Shows the parent chat link in the top bar when a parent exists. */
export const WithParentChat: Story = {
args: {
parentChat: buildChat({
id: "parent-chat-1",
title: "Root agent",
}),
},
};
/** Persisted error reason shown in the timeline area. */
export const WithError: Story = {
args: {
chatErrorReasons: { [AGENT_ID]: "Model rate limited" },
},
};
/** Input area appears disabled when `isInputDisabled` is true. */
export const InputDisabled: Story = {
args: {
isInputDisabled: true,
},
};
/** Shows a sending/pending state for the input. */
export const SubmissionPending: Story = {
args: {
isSubmissionPending: true,
},
};
/** Right sidebar panel is open with diff status data. */
export const WithSidebarPanel: Story = {
args: {
prNumber: 123,
diffStatusData: {
chat_id: AGENT_ID,
url: "https://github.com/coder/coder/pull/123",
changes_requested: false,
additions: 42,
deletions: 7,
changed_files: 5,
} satisfies ChatDiffStatusResponse,
},
};
/** Left sidebar is collapsed. */
export const SidebarCollapsed: Story = {
args: {
isSidebarCollapsed: true,
},
};
/** No model options available — shows a disabled status message. */
export const NoModelOptions: Story = {
args: {
hasModelOptions: false,
modelOptions: [],
inputStatusText: "No models configured. Ask an admin.",
isInputDisabled: true,
},
};
/** Top bar has workspace action buttons visible. */
export const WithWorkspaceActions: Story = {
args: {
canOpenEditors: true,
canOpenWorkspace: true,
sshCommand: "ssh coder.workspace",
},
};
// ---------------------------------------------------------------------------
// AgentDetailLoadingView stories
// ---------------------------------------------------------------------------
/** Default loading state with skeleton placeholders. */
export const Loading: Story = {
render: () => (
<AgentDetailLoadingView
titleElement={<title>Loading Agents</title>}
isInputDisabled
effectiveSelectedModel="openai:gpt-4o"
setSelectedModel={fn()}
modelOptions={defaultModelOptions}
modelSelectorPlaceholder="Select a model"
hasModelOptions
inputStatusText={null}
modelCatalogStatusMessage={null}
isSidebarCollapsed={false}
onToggleSidebarCollapsed={fn()}
/>
),
};
/** Loading state with the model selector populated. */
export const LoadingWithModelOptions: Story = {
render: () => (
<AgentDetailLoadingView
titleElement={<title>Loading Agents</title>}
isInputDisabled={false}
effectiveSelectedModel="openai:gpt-4o"
setSelectedModel={fn()}
modelOptions={defaultModelOptions}
modelSelectorPlaceholder="Select a model"
hasModelOptions
inputStatusText={null}
modelCatalogStatusMessage={null}
isSidebarCollapsed={false}
onToggleSidebarCollapsed={fn()}
/>
),
};
/** Loading state with the left sidebar collapsed. */
export const LoadingSidebarCollapsed: Story = {
render: () => (
<AgentDetailLoadingView
titleElement={<title>Loading Agents</title>}
isInputDisabled
effectiveSelectedModel="openai:gpt-4o"
setSelectedModel={fn()}
modelOptions={defaultModelOptions}
modelSelectorPlaceholder="Select a model"
hasModelOptions
inputStatusText={null}
modelCatalogStatusMessage={null}
isSidebarCollapsed
onToggleSidebarCollapsed={fn()}
/>
),
};
// ---------------------------------------------------------------------------
// AgentDetailNotFoundView stories
// ---------------------------------------------------------------------------
/** Shows the "Chat not found" message. */
export const NotFound: Story = {
render: () => (
<AgentDetailNotFoundView
titleElement={<title>Not Found Agents</title>}
isSidebarCollapsed={false}
onToggleSidebarCollapsed={fn()}
/>
),
};
/** "Chat not found" with the left sidebar collapsed. */
export const NotFoundSidebarCollapsed: Story = {
render: () => (
<AgentDetailNotFoundView
titleElement={<title>Not Found Agents</title>}
isSidebarCollapsed
onToggleSidebarCollapsed={fn()}
/>
),
};
@@ -0,0 +1,488 @@
import type { ChatDiffStatusResponse } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { Skeleton } from "components/Skeleton/Skeleton";
import { ArchiveIcon } from "lucide-react";
import { type FC, type RefObject, useState } from "react";
import { cn } from "utils/cn";
import { pageTitle } from "utils/page";
import { AgentChatInput, type ChatMessageInputRef } from "./AgentChatInput";
import { AgentDetailInput, AgentDetailTimeline } from "./AgentDetail";
import type { useChatStore } from "./AgentDetail/ChatContext";
import { AgentDetailTopBar } from "./AgentDetail/TopBar";
import { GitPanel } from "./GitPanel";
import { RightPanel } from "./RightPanel";
import { type SidebarTab, SidebarTabView } from "./SidebarTabView";
type ChatStoreHandle = ReturnType<typeof useChatStore>["store"];
// Re-use the inner presentational components directly. They are
interface EditingState {
chatInputRef: RefObject<ChatMessageInputRef | null>;
editorInitialValue: string;
editingMessageId: number | null;
editingFileBlocks: readonly {
mediaType: string;
data?: string;
fileId?: string;
}[];
handleEditUserMessage: (
messageId: number,
text: string,
fileBlocks?: readonly {
mediaType: string;
data?: string;
fileId?: string;
}[],
) => void;
handleCancelHistoryEdit: () => void;
editingQueuedMessageID: number | null;
handleStartQueueEdit: (id: number, text: string) => void;
handleCancelQueueEdit: () => void;
handleSendFromInput: (message: string, fileIds?: string[]) => void;
handleContentChange: (content: string) => void;
}
interface AgentDetailViewProps {
// Chat data.
agentId: string;
chatTitle: string | undefined;
parentChat: TypesGen.Chat | undefined;
chatErrorReasons: Record<string, string>;
chatRecord: TypesGen.Chat | undefined;
isArchived: boolean;
hasWorkspace: boolean;
// Store handle.
store: ChatStoreHandle;
// Editing state.
editing: EditingState;
pendingEditMessageId: number | null;
// Model/input configuration.
effectiveSelectedModel: string;
setSelectedModel: (model: string) => void;
modelOptions: readonly ModelSelectorOption[];
modelSelectorPlaceholder: string;
hasModelOptions: boolean;
inputStatusText: string | null;
modelCatalogStatusMessage: string | null;
compressionThreshold: number | undefined;
isInputDisabled: boolean;
isSubmissionPending: boolean;
isInterruptPending: boolean;
// Sidebar / panel state.
isSidebarCollapsed: boolean;
onToggleSidebarCollapsed: () => void;
// Sidebar content data.
prNumber: number | undefined;
diffStatusData: ChatDiffStatusResponse | undefined;
gitWatcher: {
repositories: ReadonlyMap<string, TypesGen.WorkspaceAgentRepoChanges>;
refresh: () => void;
};
// Workspace action handlers.
canOpenEditors: boolean;
canOpenWorkspace: boolean;
sshCommand: string | undefined;
handleOpenInEditor: (editor: "cursor" | "vscode") => void;
handleViewWorkspace: () => void;
handleOpenTerminal: () => void;
handleCommit: (repoRoot: string) => void;
// Navigation.
onNavigateToChat: (chatId: string) => void;
// Chat action handlers.
handleInterrupt: () => void;
handleDeleteQueuedMessage: (id: number) => Promise<void>;
handlePromoteQueuedMessage: (id: number) => Promise<void>;
// Archive actions.
handleArchiveAgentAction: () => void;
handleUnarchiveAgentAction: () => void;
handleArchiveAndDeleteWorkspaceAction: () => void;
// Scroll container ref.
scrollContainerRef: RefObject<HTMLDivElement | null>;
}
export const AgentDetailView: FC<AgentDetailViewProps> = ({
agentId,
chatTitle,
parentChat,
chatErrorReasons,
chatRecord,
isArchived,
hasWorkspace,
store,
editing,
pendingEditMessageId,
effectiveSelectedModel,
setSelectedModel,
modelOptions,
modelSelectorPlaceholder,
hasModelOptions,
inputStatusText,
modelCatalogStatusMessage,
compressionThreshold,
isInputDisabled,
isSubmissionPending,
isInterruptPending,
isSidebarCollapsed,
onToggleSidebarCollapsed,
prNumber,
diffStatusData,
gitWatcher,
canOpenEditors,
canOpenWorkspace,
sshCommand,
handleOpenInEditor,
handleViewWorkspace,
handleOpenTerminal,
handleCommit,
onNavigateToChat,
handleInterrupt,
handleDeleteQueuedMessage,
handlePromoteQueuedMessage,
handleArchiveAgentAction,
handleUnarchiveAgentAction,
handleArchiveAndDeleteWorkspaceAction,
scrollContainerRef,
}) => {
// Panel/sidebar UI state purely visual, no data-fetching
// implications.
const [showSidebarPanel, setShowSidebarPanel] = useState(false);
const [isRightPanelExpanded, setIsRightPanelExpanded] = useState(false);
const [dragVisualExpanded, setDragVisualExpanded] = useState<boolean | null>(
null,
);
const visualExpanded = dragVisualExpanded ?? isRightPanelExpanded;
// Derive trivial booleans the View can compute itself.
const hasDiffStatus = Boolean(diffStatusData?.url);
const hasGitRepos = gitWatcher.repositories.size > 0;
// Auto-open the diff panel when diff status first appears.
// See: https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
const [prevHasDiffStatus, setPrevHasDiffStatus] = useState(false);
if (hasDiffStatus !== prevHasDiffStatus) {
setPrevHasDiffStatus(hasDiffStatus);
if (hasDiffStatus && !window.matchMedia("(max-width: 767px)").matches) {
setShowSidebarPanel(true);
}
}
// Auto-open sidebar when git watcher receives its first non-empty
// repositories update.
const [prevHasGitRepos, setPrevHasGitRepos] = useState(false);
if (hasGitRepos !== prevHasGitRepos) {
setPrevHasGitRepos(hasGitRepos);
if (hasGitRepos && !window.matchMedia("(max-width: 767px)").matches) {
setShowSidebarPanel(true);
}
}
const titleElement = (
<title>
{chatTitle ? pageTitle(chatTitle, "Agents") : pageTitle("Agents")}
</title>
);
const shouldShowSidebar = showSidebarPanel;
return (
<div
className={cn(
"relative flex min-h-0 min-w-0 flex-1",
shouldShowSidebar && !visualExpanded && "flex-row",
)}
>
{titleElement}
<div
className={cn(
"relative flex min-h-0 min-w-0 flex-1 flex-col",
visualExpanded && "hidden",
shouldShowSidebar && "max-md:hidden",
)}
>
<div className="relative z-10 shrink-0 overflow-visible">
<AgentDetailTopBar
chatTitle={chatTitle}
parentChat={parentChat}
onOpenParentChat={(chatId) => onNavigateToChat(chatId)}
panel={{
showSidebarPanel,
onToggleSidebar: () => setShowSidebarPanel((prev) => !prev),
}}
workspace={{
canOpenEditors,
canOpenWorkspace,
onOpenInEditor: handleOpenInEditor,
onViewWorkspace: handleViewWorkspace,
onOpenTerminal: handleOpenTerminal,
sshCommand,
}}
onArchiveAgent={handleArchiveAgentAction}
onUnarchiveAgent={handleUnarchiveAgentAction}
onArchiveAndDeleteWorkspace={handleArchiveAndDeleteWorkspaceAction}
hasWorkspace={hasWorkspace}
isArchived={isArchived}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
{isArchived && (
<div className="flex shrink-0 items-center gap-2 border-b border-border-default bg-surface-secondary px-4 py-2 text-xs text-content-secondary">
<ArchiveIcon className="h-4 w-4 shrink-0" />
This agent has been archived and is read-only.
</div>
)}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-full z-10 h-6 bg-surface-primary"
style={{
maskImage:
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
}}
/>
</div>
<div
ref={scrollContainerRef}
className="flex min-h-0 flex-1 flex-col-reverse overflow-y-auto [scrollbar-gutter:stable] [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]"
>
<div className="px-4">
<AgentDetailTimeline
store={store}
chatID={agentId}
persistedErrorReason={
chatErrorReasons[agentId] || chatRecord?.last_error || undefined
}
onEditUserMessage={editing.handleEditUserMessage}
editingMessageId={editing.editingMessageId}
savingMessageId={pendingEditMessageId}
/>
</div>
</div>
<div className="shrink-0 overflow-y-auto px-4 [scrollbar-gutter:stable] [scrollbar-width:thin]">
<AgentDetailInput
store={store}
compressionThreshold={compressionThreshold}
onSend={editing.handleSendFromInput}
onDeleteQueuedMessage={handleDeleteQueuedMessage}
onPromoteQueuedMessage={handlePromoteQueuedMessage}
onInterrupt={handleInterrupt}
isInputDisabled={isInputDisabled}
isSendPending={isSubmissionPending}
isInterruptPending={isInterruptPending}
hasModelOptions={hasModelOptions}
selectedModel={effectiveSelectedModel}
onModelChange={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
inputRef={editing.chatInputRef}
initialValue={editing.editorInitialValue}
onContentChange={editing.handleContentChange}
editingQueuedMessageID={editing.editingQueuedMessageID}
onStartQueueEdit={editing.handleStartQueueEdit}
onCancelQueueEdit={editing.handleCancelQueueEdit}
isEditingHistoryMessage={editing.editingMessageId !== null}
onCancelHistoryEdit={editing.handleCancelHistoryEdit}
editingFileBlocks={editing.editingFileBlocks}
/>
</div>
</div>
<RightPanel
isOpen={shouldShowSidebar}
isExpanded={isRightPanelExpanded}
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
onClose={() => setShowSidebarPanel(false)}
onVisualExpandedChange={setDragVisualExpanded}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
>
<SidebarTabView
tabs={
[
(hasDiffStatus || hasGitRepos) && {
id: "git",
label: "Git",
content: (
<GitPanel
prTab={
prNumber && agentId
? { prNumber, chatId: agentId }
: undefined
}
repositories={gitWatcher.repositories}
onRefresh={gitWatcher.refresh}
onCommit={handleCommit}
isExpanded={visualExpanded}
remoteDiffStats={diffStatusData}
chatInputRef={editing.chatInputRef}
/>
),
},
].filter(Boolean) as SidebarTab[]
}
onClose={() => setShowSidebarPanel(false)}
isExpanded={visualExpanded}
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
chatTitle={chatTitle}
/>
</RightPanel>{" "}
</div>
);
};
interface AgentDetailLoadingViewProps {
titleElement: React.ReactNode;
isInputDisabled: boolean;
effectiveSelectedModel: string;
setSelectedModel: (model: string) => void;
modelOptions: readonly ModelSelectorOption[];
modelSelectorPlaceholder: string;
hasModelOptions: boolean;
inputStatusText: string | null;
modelCatalogStatusMessage: string | null;
isSidebarCollapsed: boolean;
onToggleSidebarCollapsed: () => void;
}
export const AgentDetailLoadingView: FC<AgentDetailLoadingViewProps> = ({
titleElement,
isInputDisabled,
effectiveSelectedModel,
setSelectedModel,
modelOptions,
modelSelectorPlaceholder,
hasModelOptions,
inputStatusText,
modelCatalogStatusMessage,
isSidebarCollapsed,
onToggleSidebarCollapsed,
}) => {
return (
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col">
{titleElement}
<AgentDetailTopBar
panel={{
showSidebarPanel: false,
onToggleSidebar: () => {},
}}
workspace={{
canOpenEditors: false,
canOpenWorkspace: false,
onOpenInEditor: () => {},
onViewWorkspace: () => {},
onOpenTerminal: () => {},
sshCommand: undefined,
}}
onOpenParentChat={() => {}}
onArchiveAgent={() => {}}
onUnarchiveAgent={() => {}}
onArchiveAndDeleteWorkspace={() => {}}
hasWorkspace={false}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
<div className="flex min-h-0 flex-1 flex-col-reverse overflow-hidden">
<div className="px-4">
<div className="mx-auto w-full max-w-3xl py-6">
<div className="flex flex-col gap-3">
{/* User message bubble (right-aligned) */}
<div className="flex w-full justify-end">
<Skeleton className="h-10 w-2/3 rounded-lg" />
</div>
{/* Assistant response lines (left-aligned) */}
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</div>
{/* Second user message bubble */}
<div className="mt-3 flex w-full justify-end">
<Skeleton className="h-10 w-1/2 rounded-lg" />
</div>
{/* Second assistant response */}
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/5" />
</div>{" "}
</div>
</div>
</div>
</div>
<div className="shrink-0 px-4">
<AgentChatInput
onSend={() => {}}
initialValue=""
isDisabled={isInputDisabled}
isLoading={false}
selectedModel={effectiveSelectedModel}
onModelChange={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
hasModelOptions={hasModelOptions}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
/>
</div>
</div>
);
};
interface AgentDetailNotFoundViewProps {
titleElement: React.ReactNode;
isSidebarCollapsed: boolean;
onToggleSidebarCollapsed: () => void;
}
export const AgentDetailNotFoundView: FC<AgentDetailNotFoundViewProps> = ({
titleElement,
isSidebarCollapsed,
onToggleSidebarCollapsed,
}) => {
return (
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col">
{titleElement}
<AgentDetailTopBar
panel={{
showSidebarPanel: false,
onToggleSidebar: () => {},
}}
workspace={{
canOpenEditors: false,
canOpenWorkspace: false,
onOpenInEditor: () => {},
onViewWorkspace: () => {},
onOpenTerminal: () => {},
sshCommand: undefined,
}}
onOpenParentChat={() => {}}
onArchiveAgent={() => {}}
onUnarchiveAgent={() => {}}
onArchiveAndDeleteWorkspace={() => {}}
hasWorkspace={false}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
<div className="flex flex-1 items-center justify-center text-content-secondary">
Chat not found
</div>{" "}
</div>
);
};
+54 -161
View File
@@ -7,17 +7,18 @@ import {
chatKey,
chatModelConfigs,
chatModels,
chatSystemPrompt,
chats,
chatsKey,
createChat,
unarchiveChat,
updateChatSystemPrompt,
} from "api/queries/chats";
import { workspaces } from "api/queries/workspaces";
import type * as TypesGen from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { ChevronDownIcon } from "components/AnimatedIcons/ChevronDown";
import type { ModelSelectorOption } from "components/ai-elements";
import { Button } from "components/Button/Button";
import {
Combobox,
ComboboxContent,
@@ -27,10 +28,8 @@ import {
ComboboxList,
ComboboxTrigger,
} from "components/Combobox/Combobox";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { CoderIcon } from "components/Icons/CoderIcon";
import { useAuthenticated } from "hooks";
import { MonitorIcon, PanelLeftIcon } from "lucide-react";
import { MonitorIcon } from "lucide-react";
import { useDashboard } from "modules/dashboard/useDashboard";
import {
type FC,
@@ -42,15 +41,13 @@ import {
useState,
} from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { NavLink, Outlet, useNavigate, useParams } from "react-router";
import { useNavigate, useParams } from "react-router";
import { toast } from "sonner";
import { cn } from "utils/cn";
import { pageTitle } from "utils/page";
import { createReconnectingWebSocket } from "utils/reconnectingWebSocket";
import { AgentChatInput } from "./AgentChatInput";
import { maybePlayChime } from "./AgentDetail/useAgentChime";
import { AgentsSidebar } from "./AgentsSidebar";
import { ChimeButton } from "./ChimeButton";
import type { AgentsOutletContext } from "./AgentsPageView";
import { AgentsPageView } from "./AgentsPageView";
import { ConfigureAgentsDialog } from "./ConfigureAgentsDialog";
import {
getModelCatalogStatusMessage,
@@ -61,18 +58,16 @@ import {
import { useAgentsPageKeybindings } from "./useAgentsPageKeybindings";
import { useAgentsPWA } from "./useAgentsPWA";
import { useFileAttachments } from "./useFileAttachments";
import { WebPushButton } from "./WebPushButton";
/** @internal Exported for testing. */
export const emptyInputStorageKey = "agents.empty-input";
const selectedWorkspaceIdStorageKey = "agents.selected-workspace-id";
const lastModelConfigIDStorageKey = "agents.last-model-config-id";
const systemPromptStorageKey = "agents.system-prompt";
const nilUUID = "00000000-0000-0000-0000-000000000000";
type ChatModelOption = ModelSelectorOption;
type CreateChatOptions = {
export type CreateChatOptions = {
message: string;
fileIDs?: string[];
workspaceId?: string;
@@ -93,19 +88,7 @@ function isChatListSSEEvent(
);
}
export interface AgentsOutletContext {
chatErrorReasons: Record<string, string>;
setChatErrorReason: (chatId: string, reason: string) => void;
clearChatErrorReason: (chatId: string) => void;
requestArchiveAgent: (chatId: string) => void;
requestUnarchiveAgent: (chatId: string) => void;
requestArchiveAndDeleteWorkspace: (
chatId: string,
workspaceId: string,
) => void;
isSidebarCollapsed: boolean;
onToggleSidebarCollapsed: () => void;
}
export type { AgentsOutletContext } from "./AgentsPageView";
const AgentsPage: FC = () => {
useAgentsPWA();
@@ -117,7 +100,6 @@ const AgentsPage: FC = () => {
const isAgentsAdmin =
permissions.editDeploymentConfig ||
user.roles.some((role) => role.name === "owner" || role.name === "admin");
const canSetSystemPrompt = isAgentsAdmin;
// The global CSS sets scrollbar-gutter: stable on <html> to prevent
// layout shift on pages that toggle scrollbars. The agents page
@@ -171,7 +153,6 @@ const AgentsPage: FC = () => {
...archiveChatBase,
onSuccess: (_data, chatId) => {
clearChatErrorReason(chatId);
toast.success("Agent archived.");
},
onError: (error, chatId, context) => {
archiveChatBase.onError(error, chatId, context);
@@ -194,8 +175,6 @@ const AgentsPage: FC = () => {
clearChatErrorReason(chatId);
await queryClient.invalidateQueries({ queryKey: chatsKey });
await queryClient.invalidateQueries({ queryKey: chatKey(chatId) });
toast.success("Agent archived.");
toast.success("Workspace deletion initiated.");
},
onError: (error) => {
toast.error(getErrorMessage(error, "Failed to archive agent."));
@@ -204,16 +183,11 @@ const AgentsPage: FC = () => {
const unarchiveChatBase = unarchiveChat(queryClient);
const unarchiveAgentMutation = useMutation({
...unarchiveChatBase,
onSuccess: () => {
toast.success("Agent unarchived.");
},
onError: (error, chatId, context) => {
unarchiveChatBase.onError(error, chatId, context);
toast.error(getErrorMessage(error, "Failed to unarchive agent."));
},
});
const [isConfigureAgentsDialogOpen, setConfigureAgentsDialogOpen] =
useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [chatErrorReasons, setChatErrorReasons] = useState<
Record<string, string>
@@ -519,108 +493,31 @@ const AgentsPage: FC = () => {
});
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-surface-primary md:flex-row">
<title>{pageTitle("Agents")}</title>
<div
className={cn(
"md:h-full md:w-[320px] md:min-h-0 md:border-b-0",
agentId
? "hidden md:block shrink-0 h-[42dvh] min-h-[240px] border-b border-border-default"
: "order-2 md:order-none flex-1 min-h-0 border-t border-border-default md:flex-none md:border-t-0",
isSidebarCollapsed && "md:hidden",
)}
>
<AgentsSidebar
chats={chatList}
chatErrorReasons={chatErrorReasons}
modelOptions={catalogModelOptions}
modelConfigs={chatModelConfigsQuery.data ?? []}
logoUrl={appearance.logo_url}
onArchiveAgent={requestArchiveAgent}
onUnarchiveAgent={requestUnarchiveAgent}
onArchiveAndDeleteWorkspace={requestArchiveAndDeleteWorkspace}
onNewAgent={handleNewAgent}
isCreating={createMutation.isPending}
isArchiving={isArchiving}
archivingChatId={archivingChatId}
isLoading={chatsQuery.isLoading}
loadError={chatsQuery.isError ? chatsQuery.error : undefined}
onRetryLoad={() => void chatsQuery.refetch()}
onCollapse={() => setIsSidebarCollapsed(true)}
/>
</div>
<div
className={cn(
"flex min-h-0 min-w-0 flex-1 flex-col bg-surface-primary",
!agentId && "order-1 md:order-none flex-none md:flex-1",
)}
>
{agentId ? (
<Outlet key={agentId} context={outletContext} />
) : (
<>
<div className="flex shrink-0 items-center gap-2 px-4 py-0.5">
<NavLink
to="/workspaces"
className="inline-flex shrink-0 md:hidden"
>
{appearance.logo_url ? (
<ExternalImage
className="h-6"
src={appearance.logo_url}
alt="Logo"
/>
) : (
<CoderIcon className="h-6 w-6 fill-content-primary" />
)}
</NavLink>
{isSidebarCollapsed && (
<Button
variant="subtle"
size="icon"
onClick={() => setIsSidebarCollapsed(false)}
aria-label="Expand sidebar"
className="hidden h-7 w-7 min-w-0 shrink-0 md:inline-flex"
>
<PanelLeftIcon />
</Button>
)}
<div className="flex min-w-0 flex-1 items-center" />
<div className="flex items-center gap-2">
<ChimeButton />
<WebPushButton />{" "}
{isAgentsAdmin && (
<Button
variant="subtle"
disabled={createMutation.isPending}
className="h-8 gap-1.5 border-none bg-transparent px-1 text-[13px] shadow-none hover:bg-transparent"
onClick={() => setConfigureAgentsDialogOpen(true)}
>
Admin
</Button>
)}
</div>
</div>
<AgentsEmptyState
onCreateChat={handleCreateChat}
isCreating={createMutation.isPending}
createError={createMutation.error}
modelCatalog={chatModelsQuery.data}
modelOptions={catalogModelOptions}
modelConfigs={chatModelConfigsQuery.data ?? []}
isModelCatalogLoading={chatModelsQuery.isLoading}
isModelConfigsLoading={chatModelConfigsQuery.isLoading}
modelCatalogError={chatModelsQuery.error}
canSetSystemPrompt={canSetSystemPrompt}
canManageChatModelConfigs={isAgentsAdmin}
isConfigureAgentsDialogOpen={isConfigureAgentsDialogOpen}
onConfigureAgentsDialogOpenChange={setConfigureAgentsDialogOpen}
/>
</>
)}
</div>
</div>
<AgentsPageView
agentId={agentId}
chatList={chatList}
catalogModelOptions={catalogModelOptions}
modelConfigs={chatModelConfigsQuery.data ?? []}
logoUrl={appearance.logo_url}
handleNewAgent={handleNewAgent}
isCreating={createMutation.isPending}
isArchiving={isArchiving}
archivingChatId={archivingChatId}
isChatsLoading={chatsQuery.isLoading}
chatsLoadError={chatsQuery.error}
onRetryChatsLoad={() => void chatsQuery.refetch()}
onCollapseSidebar={() => setIsSidebarCollapsed(true)}
isSidebarCollapsed={isSidebarCollapsed}
onExpandSidebar={() => setIsSidebarCollapsed(false)}
outletContext={outletContext}
onCreateChat={handleCreateChat}
createError={createMutation.error}
modelCatalog={chatModelsQuery.data}
isModelCatalogLoading={chatModelsQuery.isLoading}
isModelConfigsLoading={chatModelConfigsQuery.isLoading}
modelCatalogError={chatModelsQuery.error}
isAgentsAdmin={isAgentsAdmin}
/>
);
};
@@ -678,7 +575,7 @@ export function useEmptyStateDraft() {
};
}
interface AgentsEmptyStateProps {
interface AgentCreateFormProps {
onCreateChat: (options: CreateChatOptions) => Promise<void>;
isCreating: boolean;
createError: unknown;
@@ -694,7 +591,7 @@ interface AgentsEmptyStateProps {
onConfigureAgentsDialogOpenChange: (open: boolean) => void;
}
export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
export const AgentCreateForm: FC<AgentCreateFormProps> = ({
onCreateChat,
isCreating,
createError,
@@ -710,14 +607,15 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
onConfigureAgentsDialogOpenChange,
}) => {
const { organizations } = useDashboard();
const queryClient = useQueryClient();
const { initialInputValue, handleContentChange, submitDraft, resetDraft } =
useEmptyStateDraft();
const initialSystemPrompt = () => {
if (typeof window === "undefined") {
return "";
}
return localStorage.getItem(systemPromptStorageKey) ?? "";
};
const systemPromptQuery = useQuery(chatSystemPrompt());
const {
mutate: saveSystemPrompt,
isPending: isSavingSystemPrompt,
isError: isSaveSystemPromptError,
} = useMutation(updateChatSystemPrompt(queryClient));
const [initialLastModelConfigID] = useState(() => {
if (typeof window === "undefined") {
return "";
@@ -777,10 +675,9 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
modelOptions.some((modelOption) => modelOption.id === userSelectedModel)
? userSelectedModel
: preferredModelID;
const [savedSystemPrompt, setSavedSystemPrompt] =
useState(initialSystemPrompt);
const [systemPromptDraft, setSystemPromptDraft] =
useState(initialSystemPrompt);
const serverPrompt = systemPromptQuery.data?.system_prompt ?? "";
const [localEdit, setLocalEdit] = useState<string | null>(null);
const systemPromptDraft = localEdit ?? serverPrompt;
const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 }));
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>(
() => {
@@ -838,7 +735,7 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
selectedWorkspaceIdRef.current = selectedWorkspaceId;
const selectedModelRef = useRef(selectedModel);
selectedModelRef.current = selectedModel;
const isSystemPromptDirty = systemPromptDraft !== savedSystemPrompt;
const isSystemPromptDirty = localEdit !== null && localEdit !== serverPrompt;
const handleWorkspaceChange = (value: string) => {
if (value === autoCreateWorkspaceValue) {
@@ -865,17 +762,12 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
if (!isSystemPromptDirty) {
return;
}
setSavedSystemPrompt(systemPromptDraft);
if (typeof window !== "undefined") {
if (systemPromptDraft) {
localStorage.setItem(systemPromptStorageKey, systemPromptDraft);
} else {
localStorage.removeItem(systemPromptStorageKey);
}
}
saveSystemPrompt(
{ system_prompt: systemPromptDraft },
{ onSuccess: () => setLocalEdit(null) },
);
},
[isSystemPromptDirty, systemPromptDraft],
[isSystemPromptDirty, systemPromptDraft, saveSystemPrompt],
);
const handleSend = useCallback(
@@ -1019,10 +911,11 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
canManageChatModelConfigs={canManageChatModelConfigs}
canSetSystemPrompt={canSetSystemPrompt}
systemPromptDraft={systemPromptDraft}
onSystemPromptDraftChange={setSystemPromptDraft}
onSystemPromptDraftChange={setLocalEdit}
onSaveSystemPrompt={handleSaveSystemPrompt}
isSystemPromptDirty={isSystemPromptDirty}
isDisabled={isCreating}
saveSystemPromptError={isSaveSystemPromptError}
isDisabled={isCreating || isSavingSystemPrompt}
/>
)}
</div>
@@ -0,0 +1,301 @@
import { MockUserOwner } from "testHelpers/entities";
import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import type { Chat } from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { fn, spyOn } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { AgentsPageView } from "./AgentsPageView";
const defaultModelOptions: ModelSelectorOption[] = [
{
id: "openai:gpt-4o",
provider: "openai",
model: "gpt-4o",
displayName: "GPT-4o",
},
];
const defaultModelConfigs: TypesGen.ChatModelConfig[] = [
{
id: "config-openai-gpt-4o",
provider: "openai",
model: "gpt-4o",
display_name: "GPT-4o",
enabled: true,
is_default: false,
context_limit: 200000,
compression_threshold: 70,
created_at: "2026-02-18T00:00:00.000Z",
updated_at: "2026-02-18T00:00:00.000Z",
},
];
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const todayTimestamp = new Date().toISOString();
const buildChat = (overrides: Partial<Chat> = {}): Chat => ({
id: "chat-default",
owner_id: "owner-1",
title: "Agent",
status: "completed",
last_model_config_id: defaultModelConfigs[0].id,
created_at: oneWeekAgo,
updated_at: oneWeekAgo,
archived: false,
last_error: null,
...overrides,
});
const agentsRouting = [
{ path: "/agents/:agentId", useStoryElement: true },
{ path: "/agents", useStoryElement: true },
] satisfies [
{ path: string; useStoryElement: boolean },
...{ path: string; useStoryElement: boolean }[],
];
const meta: Meta<typeof AgentsPageView> = {
title: "pages/AgentsPage/AgentsPageView",
component: AgentsPageView,
decorators: [withAuthProvider, withDashboardProvider],
parameters: {
layout: "fullscreen",
user: MockUserOwner,
reactRouter: reactRouterParameters({
location: { path: "/agents" },
routing: agentsRouting,
}),
},
args: {
agentId: undefined,
chatList: [],
catalogModelOptions: defaultModelOptions,
modelConfigs: defaultModelConfigs,
logoUrl: "",
handleNewAgent: fn(),
isCreating: false,
isArchiving: false,
archivingChatId: undefined,
isChatsLoading: false,
chatsLoadError: null,
onRetryChatsLoad: fn(),
onCollapseSidebar: fn(),
isSidebarCollapsed: false,
onExpandSidebar: fn(),
outletContext: {
chatErrorReasons: {},
setChatErrorReason: fn(),
clearChatErrorReason: fn(),
requestArchiveAgent: fn(),
requestUnarchiveAgent: fn(),
requestArchiveAndDeleteWorkspace: fn(),
isSidebarCollapsed: false,
onToggleSidebarCollapsed: fn(),
},
isAgentsAdmin: false,
onCreateChat: fn(),
createError: undefined,
modelCatalog: undefined,
isModelCatalogLoading: false,
isModelConfigsLoading: false,
modelCatalogError: undefined,
},
beforeEach: () => {
spyOn(API, "getWorkspaces").mockResolvedValue({
workspaces: [],
count: 0,
});
},
};
export default meta;
type Story = StoryObj<typeof AgentsPageView>;
export const EmptyState: Story = {};
export const WithChatList: Story = {
args: {
chatList: [
buildChat({
id: "chat-1",
title: "Refactor authentication module",
status: "completed",
updated_at: todayTimestamp,
}),
buildChat({
id: "chat-2",
title: "Add unit tests for API layer",
status: "running",
updated_at: todayTimestamp,
}),
buildChat({
id: "chat-3",
title: "Fix database migration issue",
status: "error",
last_error: "Connection timeout",
updated_at: todayTimestamp,
}),
buildChat({
id: "chat-4",
title: "Update CI/CD pipeline config",
status: "waiting",
updated_at: todayTimestamp,
}),
buildChat({
id: "chat-5",
title: "Implement WebSocket handler",
status: "completed",
updated_at: todayTimestamp,
}),
buildChat({
id: "chat-6",
title: "Debug memory leak in worker",
status: "paused",
updated_at: todayTimestamp,
}),
],
},
};
export const LoadingChats: Story = {
args: {
isChatsLoading: true,
chatList: [],
},
};
export const ChatsLoadError: Story = {
args: {
chatsLoadError: new Error("Failed to fetch chats"),
},
};
export const SidebarCollapsed: Story = {
args: {
isSidebarCollapsed: true,
chatList: [
buildChat({
id: "chat-1",
title: "Collapsed sidebar agent",
updated_at: todayTimestamp,
}),
],
outletContext: {
chatErrorReasons: {},
setChatErrorReason: fn(),
clearChatErrorReason: fn(),
requestArchiveAgent: fn(),
requestUnarchiveAgent: fn(),
requestArchiveAndDeleteWorkspace: fn(),
isSidebarCollapsed: true,
onToggleSidebarCollapsed: fn(),
},
},
};
export const WithToolbarEndContent: Story = {
args: {
isAgentsAdmin: true,
},
};
export const CreatingAgent: Story = {
args: {
isCreating: true,
chatList: [
buildChat({
id: "chat-1",
title: "Existing agent",
updated_at: todayTimestamp,
}),
],
},
};
export const ArchivingAgent: Story = {
args: {
isArchiving: true,
archivingChatId: "chat-1",
chatList: [
buildChat({
id: "chat-1",
title: "Agent being archived",
updated_at: todayTimestamp,
}),
buildChat({
id: "chat-2",
title: "Another agent",
updated_at: todayTimestamp,
}),
],
},
};
export const WithAgentSelected: Story = {
args: {
agentId: "chat-1",
chatList: [
buildChat({
id: "chat-1",
title: "Selected agent",
status: "running",
updated_at: todayTimestamp,
}),
buildChat({
id: "chat-2",
title: "Another agent",
updated_at: todayTimestamp,
}),
],
},
parameters: {
reactRouter: reactRouterParameters({
location: {
path: "/agents/chat-1",
pathParams: { agentId: "chat-1" },
},
routing: agentsRouting,
}),
},
};
export const WithErrorReasons: Story = {
args: {
chatList: [
buildChat({
id: "chat-1",
title: "Rate limited agent",
status: "error",
updated_at: todayTimestamp,
}),
buildChat({
id: "chat-2",
title: "Healthy agent",
status: "running",
updated_at: todayTimestamp,
}),
buildChat({
id: "chat-3",
title: "Another errored agent",
status: "error",
updated_at: todayTimestamp,
}),
],
outletContext: {
chatErrorReasons: {
"chat-1": "Model rate limited",
"chat-3": "Context window exceeded",
},
setChatErrorReason: fn(),
clearChatErrorReason: fn(),
requestArchiveAgent: fn(),
requestUnarchiveAgent: fn(),
requestArchiveAndDeleteWorkspace: fn(),
isSidebarCollapsed: false,
onToggleSidebarCollapsed: fn(),
},
},
};
@@ -0,0 +1,191 @@
import type * as TypesGen from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { Button } from "components/Button/Button";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { CoderIcon } from "components/Icons/CoderIcon";
import { PanelLeftIcon } from "lucide-react";
import { type FC, useState } from "react";
import { NavLink, Outlet } from "react-router";
import { cn } from "utils/cn";
import { pageTitle } from "utils/page";
import { AgentCreateForm, type CreateChatOptions } from "./AgentsPage";
import { AgentsSidebar } from "./AgentsSidebar";
import { ChimeButton } from "./ChimeButton";
import { WebPushButton } from "./WebPushButton";
type ChatModelOption = ModelSelectorOption;
export interface AgentsOutletContext {
chatErrorReasons: Record<string, string>;
setChatErrorReason: (chatId: string, reason: string) => void;
clearChatErrorReason: (chatId: string) => void;
requestArchiveAgent: (chatId: string) => void;
requestUnarchiveAgent: (chatId: string) => void;
requestArchiveAndDeleteWorkspace: (
chatId: string,
workspaceId: string,
) => void;
isSidebarCollapsed: boolean;
onToggleSidebarCollapsed: () => void;
}
interface AgentsPageViewProps {
agentId: string | undefined;
chatList: TypesGen.Chat[];
catalogModelOptions: readonly ChatModelOption[];
modelConfigs: readonly TypesGen.ChatModelConfig[];
logoUrl: string;
handleNewAgent: () => void;
isCreating: boolean;
isArchiving: boolean;
archivingChatId: string | undefined;
isChatsLoading: boolean;
chatsLoadError: Error | null;
onRetryChatsLoad: () => void;
onCollapseSidebar: () => void;
isSidebarCollapsed: boolean;
onExpandSidebar: () => void;
outletContext: AgentsOutletContext;
isAgentsAdmin: boolean;
onCreateChat: (options: CreateChatOptions) => Promise<void>;
createError: unknown;
modelCatalog: TypesGen.ChatModelsResponse | null | undefined;
isModelCatalogLoading: boolean;
isModelConfigsLoading: boolean;
modelCatalogError: unknown;
}
export const AgentsPageView: FC<AgentsPageViewProps> = ({
agentId,
chatList,
catalogModelOptions,
modelConfigs,
logoUrl,
handleNewAgent,
isCreating,
isArchiving,
archivingChatId,
isChatsLoading,
chatsLoadError,
onRetryChatsLoad,
onCollapseSidebar,
isSidebarCollapsed,
onExpandSidebar,
outletContext,
isAgentsAdmin,
onCreateChat,
createError,
modelCatalog,
isModelCatalogLoading,
isModelConfigsLoading,
modelCatalogError,
}) => {
const {
chatErrorReasons,
requestArchiveAgent,
requestUnarchiveAgent,
requestArchiveAndDeleteWorkspace,
} = outletContext;
const [isConfigureAgentsDialogOpen, setConfigureAgentsDialogOpen] =
useState(false);
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-surface-primary md:flex-row">
<title>{pageTitle("Agents")}</title>
<div
className={cn(
"md:h-full md:w-[320px] md:min-h-0 md:border-b-0",
agentId
? "hidden md:block shrink-0 h-[42dvh] min-h-[240px] border-b border-border-default"
: "order-2 md:order-none flex-1 min-h-0 border-t border-border-default md:flex-none md:border-t-0",
isSidebarCollapsed && "md:hidden",
)}
>
<AgentsSidebar
chats={chatList}
chatErrorReasons={chatErrorReasons}
modelOptions={catalogModelOptions}
modelConfigs={modelConfigs}
logoUrl={logoUrl}
onArchiveAgent={requestArchiveAgent}
onUnarchiveAgent={requestUnarchiveAgent}
onArchiveAndDeleteWorkspace={requestArchiveAndDeleteWorkspace}
onNewAgent={handleNewAgent}
isCreating={isCreating}
isArchiving={isArchiving}
archivingChatId={archivingChatId}
isLoading={isChatsLoading}
loadError={chatsLoadError}
onRetryLoad={onRetryChatsLoad}
onCollapse={onCollapseSidebar}
/>
</div>
<div
className={cn(
"flex min-h-0 min-w-0 flex-1 flex-col bg-surface-primary",
!agentId && "order-1 md:order-none flex-none md:flex-1",
)}
>
{agentId ? (
<Outlet key={agentId} context={outletContext} />
) : (
<>
<div className="flex shrink-0 items-center gap-2 px-4 py-0.5">
<NavLink
to="/workspaces"
className="inline-flex shrink-0 md:hidden"
>
{logoUrl ? (
<ExternalImage className="h-6" src={logoUrl} alt="Logo" />
) : (
<CoderIcon className="h-6 w-6 fill-content-primary" />
)}
</NavLink>
{isSidebarCollapsed && (
<Button
variant="subtle"
size="icon"
onClick={onExpandSidebar}
aria-label="Expand sidebar"
className="hidden h-7 w-7 min-w-0 shrink-0 md:inline-flex"
>
<PanelLeftIcon />
</Button>
)}
<div className="flex min-w-0 flex-1 items-center" />
<div className="flex items-center gap-2">
<ChimeButton />
<WebPushButton />
{isAgentsAdmin && (
<Button
variant="subtle"
disabled={isCreating}
className="h-8 gap-1.5 border-none bg-transparent px-1 text-[13px] shadow-none hover:bg-transparent"
onClick={() => setConfigureAgentsDialogOpen(true)}
>
Admin
</Button>
)}
</div>
</div>
<AgentCreateForm
onCreateChat={onCreateChat}
isCreating={isCreating}
createError={createError}
modelCatalog={modelCatalog}
modelOptions={catalogModelOptions}
modelConfigs={modelConfigs}
isModelCatalogLoading={isModelCatalogLoading}
isModelConfigsLoading={isModelConfigsLoading}
modelCatalogError={modelCatalogError}
canSetSystemPrompt={isAgentsAdmin}
canManageChatModelConfigs={isAgentsAdmin}
isConfigureAgentsDialogOpen={isConfigureAgentsDialogOpen}
onConfigureAgentsDialogOpenChange={setConfigureAgentsDialogOpen}
/>
</>
)}
</div>
</div>
);
};
@@ -78,6 +78,7 @@ const meta: Meta<typeof ConfigureAgentsDialog> = {
onSystemPromptDraftChange: fn(),
onSaveSystemPrompt: fn(),
isSystemPromptDirty: false,
saveSystemPromptError: false,
isDisabled: false,
},
};
@@ -32,6 +32,7 @@ interface ConfigureAgentsDialogProps {
onSystemPromptDraftChange: (value: string) => void;
onSaveSystemPrompt: (event: FormEvent) => void;
isSystemPromptDirty: boolean;
saveSystemPromptError: boolean;
isDisabled: boolean;
}
@@ -44,6 +45,7 @@ export const ConfigureAgentsDialog: FC<ConfigureAgentsDialogProps> = ({
onSystemPromptDraftChange,
onSaveSystemPrompt,
isSystemPromptDirty,
saveSystemPromptError,
isDisabled,
}) => {
const configureSectionOptions = useMemo<
@@ -152,7 +154,8 @@ export const ConfigureAgentsDialog: FC<ConfigureAgentsDialogProps> = ({
System Prompt
</h3>
<p className="m-0 text-xs text-content-secondary">
Admin-only instruction applied to all new chats.
Admin-only instruction applied to all new chats. When empty,
the built-in default prompt is used.
</p>
<TextareaAutosize
className="min-h-[220px] w-full resize-y rounded-lg border border-border bg-surface-primary px-4 py-3 font-sans text-[13px] leading-relaxed text-content-primary placeholder:text-content-secondary focus:outline-none focus:ring-2 focus:ring-content-link/30"
@@ -182,6 +185,11 @@ export const ConfigureAgentsDialog: FC<ConfigureAgentsDialogProps> = ({
Save
</Button>
</div>
{saveSystemPromptError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save system prompt.
</p>
)}
</div>
</form>
</>
+2 -7
View File
@@ -22,17 +22,12 @@ export const WebPushButton: FC = () => {
try {
if (webPush.subscribed) {
await webPush.unsubscribe();
toast.success("Notifications disabled.");
} else {
await webPush.subscribe();
toast.success("Notifications enabled.");
}
} catch (error) {
if (webPush.subscribed) {
toast.error(getErrorMessage(error, "Failed to disable notifications."));
} else {
toast.error(getErrorMessage(error, "Failed to enable notifications."));
}
const action = webPush.subscribed ? "disable" : "enable";
toast.error(getErrorMessage(error, `Failed to ${action} notifications.`));
}
};