Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61175ffc07 | |||
| 9eb68d1b9c | |||
| d61772dc52 | |||
| c933ddcffd | |||
| a21f00d250 | |||
| 3167908358 | |||
| 45f62d1487 | |||
| b850d40db8 | |||
| 73bf8478d8 | |||
| 41c505f03b | |||
| abdfadf8cb | |||
| d936a99e6b | |||
| 14341edfc2 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = () => ({
|
||||
|
||||
Generated
+16
@@ -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) =>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
+12
-10
@@ -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.",
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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.`));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user