Merge branch 'main' into ao/reverbcode-2/issue-2272-prompts
This commit is contained in:
commit
077042ae56
|
|
@ -236,7 +236,7 @@ func (r *Runtime) Attach(ctx context.Context, handle ports.RuntimeHandle, rows,
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ptyexec.Spawn(ctx, argv, nil, rows, cols)
|
||||
return ptyexec.Spawn(ctx, argv, attachEnv(os.Environ()), rows, cols)
|
||||
}
|
||||
|
||||
// attachCommand returns the argv to attach a terminal to the session.
|
||||
|
|
@ -249,6 +249,17 @@ func (r *Runtime) attachCommand(handle ports.RuntimeHandle) ([]string, error) {
|
|||
return []string{r.binary, "attach-session", "-t", id}, nil
|
||||
}
|
||||
|
||||
func attachEnv(base []string) []string {
|
||||
env := append([]string(nil), base...)
|
||||
for i, kv := range env {
|
||||
if strings.HasPrefix(kv, "TERM=") {
|
||||
env[i] = "TERM=xterm-256color"
|
||||
return env
|
||||
}
|
||||
}
|
||||
return append(env, "TERM=xterm-256color")
|
||||
}
|
||||
|
||||
// run wraps runner.Run with a per-call timeout context.
|
||||
func (r *Runtime) run(ctx context.Context, args ...string) ([]byte, error) {
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, r.timeout)
|
||||
|
|
|
|||
|
|
@ -551,6 +551,18 @@ func TestAttachCommandRejectsInvalidHandle(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAttachEnvForcesUsableTerm(t *testing.T) {
|
||||
env := attachEnv([]string{"PATH=/bin", "TERM=dumb", "SHELL=/bin/sh"})
|
||||
if got, want := env, []string{"PATH=/bin", "TERM=xterm-256color", "SHELL=/bin/sh"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("attachEnv = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
env = attachEnv([]string{"PATH=/bin"})
|
||||
if got, want := env, []string{"PATH=/bin", "TERM=xterm-256color"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("attachEnv without TERM = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// -- commandError tests --
|
||||
|
||||
func TestCommandErrorUnwraps(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -307,7 +307,7 @@ func (c *commandContext) checkTerminalRuntime(ctx context.Context) doctorCheck {
|
|||
func (c *commandContext) checkTmux(ctx context.Context) doctorCheck {
|
||||
path, err := c.deps.LookPath("tmux")
|
||||
if err != nil || path == "" {
|
||||
return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "tmux", Message: "not found in PATH"}
|
||||
return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "tmux", Message: "not found in PATH; required on macOS/Linux to start sessions"}
|
||||
}
|
||||
reqCtx, cancel := context.WithTimeout(ctx, probeTimeout)
|
||||
defer cancel()
|
||||
|
|
|
|||
|
|
@ -247,12 +247,18 @@ func TestDoctorJSONOutputIsDecodable(t *testing.T) {
|
|||
clearDoctorGitHubEnv(t)
|
||||
out, errOut, err := executeCLI(t, Deps{
|
||||
LookPath: func(name string) (string, error) {
|
||||
if name == "git" {
|
||||
switch name {
|
||||
case "git":
|
||||
return "/bin/git", nil
|
||||
case "tmux":
|
||||
return "/bin/tmux", nil
|
||||
}
|
||||
return "", errors.New("missing")
|
||||
},
|
||||
CommandOutput: func(context.Context, string, ...string) ([]byte, error) {
|
||||
CommandOutput: func(_ context.Context, name string, _ ...string) ([]byte, error) {
|
||||
if name == "/bin/tmux" {
|
||||
return []byte("tmux 3.3a\n"), nil
|
||||
}
|
||||
return []byte("git version 2.43.0\n"), nil
|
||||
},
|
||||
ProcessAlive: func(int) bool { return false },
|
||||
|
|
@ -277,12 +283,18 @@ func TestDoctorTextOutputIsGrouped(t *testing.T) {
|
|||
clearDoctorGitHubEnv(t)
|
||||
out, errOut, err := executeCLI(t, Deps{
|
||||
LookPath: func(name string) (string, error) {
|
||||
if name == "git" {
|
||||
switch name {
|
||||
case "git":
|
||||
return "/bin/git", nil
|
||||
case "tmux":
|
||||
return "/bin/tmux", nil
|
||||
}
|
||||
return "", errors.New("missing")
|
||||
},
|
||||
CommandOutput: func(context.Context, string, ...string) ([]byte, error) {
|
||||
CommandOutput: func(_ context.Context, name string, _ ...string) ([]byte, error) {
|
||||
if name == "/bin/tmux" {
|
||||
return []byte("tmux 3.3a\n"), nil
|
||||
}
|
||||
return []byte("git version 2.43.0\n"), nil
|
||||
},
|
||||
ProcessAlive: func(int) bool { return false },
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ func TestE2E_SpawnAndProjectAddDTORoundTrip(t *testing.T) {
|
|||
"--branch", "feat/x",
|
||||
"--prompt", "hi",
|
||||
"--issue", "ISS-1",
|
||||
"--name", "my worker",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("spawn execute: %v\noutput: %s", err, out.String())
|
||||
|
|
@ -205,6 +206,9 @@ func TestE2E_SpawnAndProjectAddDTORoundTrip(t *testing.T) {
|
|||
if got.IssueID != "ISS-1" {
|
||||
t.Errorf("IssueID = %q, want %q", got.IssueID, "ISS-1")
|
||||
}
|
||||
if got.DisplayName != "my worker" {
|
||||
t.Errorf("DisplayName = %q, want %q (CLI json:\"displayName\" vs SpawnSessionRequest)", got.DisplayName, "my worker")
|
||||
}
|
||||
if !bytes.Contains(out.Bytes(), []byte("spawned session")) {
|
||||
t.Errorf("output missing %q; got: %s", "spawned session", out.String())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import (
|
|||
"fmt"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
|
@ -12,12 +14,17 @@ import (
|
|||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux"
|
||||
)
|
||||
|
||||
// maxDisplayNameLen caps the sidebar label set by `--name`. Mirrored by the
|
||||
// daemon's spawn handler so a direct API call is held to the same limit.
|
||||
const maxDisplayNameLen = 20
|
||||
|
||||
type spawnOptions struct {
|
||||
project string
|
||||
harness string
|
||||
branch string
|
||||
prompt string
|
||||
issue string
|
||||
name string
|
||||
claimPR string
|
||||
noTakeover bool
|
||||
}
|
||||
|
|
@ -25,11 +32,12 @@ type spawnOptions struct {
|
|||
// spawnRequest mirrors the daemon's SpawnSessionRequest body for
|
||||
// POST /api/v1/sessions. The CLI keeps its own copy so it need not import httpd.
|
||||
type spawnRequest struct {
|
||||
ProjectID string `json:"projectId"`
|
||||
IssueID string `json:"issueId,omitempty"`
|
||||
Harness string `json:"harness,omitempty"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
ProjectID string `json:"projectId"`
|
||||
IssueID string `json:"issueId,omitempty"`
|
||||
Harness string `json:"harness,omitempty"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
DisplayName string `json:"displayName"`
|
||||
}
|
||||
|
||||
type spawnResult struct {
|
||||
|
|
@ -52,6 +60,13 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
|
|||
if opts.project == "" {
|
||||
return usageError{fmt.Errorf("--project is required")}
|
||||
}
|
||||
name := strings.TrimSpace(opts.name)
|
||||
if name == "" {
|
||||
return usageError{fmt.Errorf("--name is required")}
|
||||
}
|
||||
if utf8.RuneCountInString(name) > maxDisplayNameLen {
|
||||
return usageError{fmt.Errorf("--name must be %d characters or fewer", maxDisplayNameLen)}
|
||||
}
|
||||
if opts.noTakeover && opts.claimPR == "" {
|
||||
return usageError{fmt.Errorf("--no-takeover requires --claim-pr")}
|
||||
}
|
||||
|
|
@ -67,11 +82,12 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
|
|||
}
|
||||
}
|
||||
req := spawnRequest{
|
||||
ProjectID: opts.project,
|
||||
IssueID: opts.issue,
|
||||
Harness: opts.harness,
|
||||
Branch: opts.branch,
|
||||
Prompt: opts.prompt,
|
||||
ProjectID: opts.project,
|
||||
IssueID: opts.issue,
|
||||
Harness: opts.harness,
|
||||
Branch: opts.branch,
|
||||
Prompt: opts.prompt,
|
||||
DisplayName: name,
|
||||
}
|
||||
var res spawnResult
|
||||
if err := ctx.postJSON(cmd.Context(), "sessions", req, &res); err != nil {
|
||||
|
|
@ -125,6 +141,7 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
|
|||
f.StringVar(&opts.branch, "branch", "", "Branch for the session worktree (default: ao/<session-id>/root)")
|
||||
f.StringVar(&opts.prompt, "prompt", "", "Initial prompt for the agent")
|
||||
f.StringVar(&opts.issue, "issue", "", "Issue id to associate with the session")
|
||||
f.StringVar(&opts.name, "name", "", "Display name shown in the sidebar (required, max 20 characters)")
|
||||
f.StringVar(&opts.claimPR, "claim-pr", "", "Immediately claim an existing PR for the spawned session")
|
||||
f.BoolVar(&opts.noTakeover, "no-takeover", false, "Refuse if another active session owns the claimed PR (requires --claim-pr)")
|
||||
return cmd
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ func TestSpawnClaimPRWiring(t *testing.T) {
|
|||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--claim-pr", "142", "--no-takeover")
|
||||
out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--name", "worker", "--claim-pr", "142", "--no-takeover")
|
||||
if err != nil {
|
||||
t.Fatalf("spawn claim-pr failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
|
|
@ -108,7 +108,7 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) {
|
|||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--claim-pr", "142")
|
||||
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--name", "worker", "--claim-pr", "142")
|
||||
if err == nil {
|
||||
t.Fatal("expected spawn claim failure")
|
||||
}
|
||||
|
|
@ -126,8 +126,26 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestSpawnNoTakeoverRequiresClaimPR(t *testing.T) {
|
||||
_, _, err := executeCLI(t, Deps{}, "spawn", "--project", "demo", "--no-takeover")
|
||||
_, _, err := executeCLI(t, Deps{}, "spawn", "--project", "demo", "--name", "worker", "--no-takeover")
|
||||
if err == nil || ExitCode(err) != 2 || !strings.Contains(err.Error(), "--no-takeover requires --claim-pr") {
|
||||
t.Fatalf("err=%v exit=%d", err, ExitCode(err))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpawnCommand_RequiresName asserts `ao spawn` rejects a missing --name
|
||||
// before touching the network, mirroring the --project guard.
|
||||
func TestSpawnCommand_RequiresName(t *testing.T) {
|
||||
_, _, err := executeCLI(t, Deps{}, "spawn", "--project", "demo")
|
||||
if err == nil || ExitCode(err) != 2 || !strings.Contains(err.Error(), "--name is required") {
|
||||
t.Fatalf("err=%v exit=%d, want --name is required", err, ExitCode(err))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpawnCommand_RejectsOverlongName asserts `ao spawn` rejects a --name
|
||||
// longer than 20 characters without contacting the daemon.
|
||||
func TestSpawnCommand_RejectsOverlongName(t *testing.T) {
|
||||
_, _, err := executeCLI(t, Deps{}, "spawn", "--project", "demo", "--name", strings.Repeat("x", 21))
|
||||
if err == nil || ExitCode(err) != 2 || !strings.Contains(err.Error(), "20 characters or fewer") {
|
||||
t.Fatalf("err=%v exit=%d, want 20 characters or fewer", err, ExitCode(err))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ type ProjectConfig struct {
|
|||
|
||||
// Reviewers names the agent(s) that review a worker's PR when a review is
|
||||
// triggered. It is configured independently of the Worker override; an empty
|
||||
// list falls back to the worker's own harness (see ResolveReviewerHarness).
|
||||
// list falls back to claude-code (see ResolveReviewerHarness).
|
||||
Reviewers []ReviewerConfig `json:"reviewers,omitempty"`
|
||||
}
|
||||
|
||||
|
|
@ -64,15 +64,11 @@ type ReviewerConfig struct {
|
|||
const FallbackReviewerHarness = ReviewerClaudeCode
|
||||
|
||||
// ResolveReviewerHarness picks the reviewer harness for a worker. A configured
|
||||
// reviewer wins; otherwise it reuses the worker's own harness when that harness
|
||||
// is also a supported reviewer, falling back to claude-code.
|
||||
func (c ProjectConfig) ResolveReviewerHarness(workerHarness AgentHarness) ReviewerHarness {
|
||||
// reviewer wins; otherwise claude-code is used.
|
||||
func (c ProjectConfig) ResolveReviewerHarness(_ AgentHarness) ReviewerHarness {
|
||||
if len(c.Reviewers) > 0 {
|
||||
return c.Reviewers[0].Harness
|
||||
}
|
||||
if h := ReviewerHarness(workerHarness); h.IsKnown() {
|
||||
return h
|
||||
}
|
||||
return FallbackReviewerHarness
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -83,13 +83,12 @@ func TestResolveReviewerHarness(t *testing.T) {
|
|||
t.Fatalf("configured reviewer = %q, want claude-code", got)
|
||||
}
|
||||
|
||||
// No reviewer configured: reuse the worker harness when it is also a
|
||||
// supported reviewer (claude-code is).
|
||||
// No reviewer configured: always use claude-code.
|
||||
if got := (ProjectConfig{}).ResolveReviewerHarness(HarnessClaudeCode); got != ReviewerClaudeCode {
|
||||
t.Fatalf("default = %q, want reviewer claude-code", got)
|
||||
}
|
||||
|
||||
// A worker harness that is not a supported reviewer falls back to claude-code.
|
||||
// A worker harness that is not claude-code also falls back to claude-code.
|
||||
if got := (ProjectConfig{}).ResolveReviewerHarness(HarnessAider); got != FallbackReviewerHarness {
|
||||
t.Fatalf("fallback = %q, want %q", got, FallbackReviewerHarness)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2470,6 +2470,9 @@ components:
|
|||
properties:
|
||||
branch:
|
||||
type: string
|
||||
displayName:
|
||||
maxLength: 20
|
||||
type: string
|
||||
harness:
|
||||
enum:
|
||||
- claude-code
|
||||
|
|
|
|||
|
|
@ -146,6 +146,10 @@ type SpawnSessionRequest struct {
|
|||
Harness domain.AgentHarness `json:"harness,omitempty" enum:"claude-code,codex,aider,opencode,grok,droid,amp,agy,crush,cursor,qwen,copilot,goose,auggie,continue,devin,cline,kimi,kiro,kilocode,vibe,pi,autohand"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
Prompt string `json:"prompt,omitempty" maxLength:"4096"`
|
||||
// DisplayName is the sidebar label for the session, capped at 20 characters.
|
||||
// `ao spawn --name` always sets it; other clients (e.g. the desktop new-task
|
||||
// dialog) may omit it and fall back to the session id in the read model.
|
||||
DisplayName string `json:"displayName,omitempty" maxLength:"20"`
|
||||
}
|
||||
|
||||
// SessionResponse is the { session } body shared by session create/get.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
|
|
@ -22,8 +23,9 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
maxPromptLen = 4096
|
||||
maxMessageLen = 4096
|
||||
maxPromptLen = 4096
|
||||
maxMessageLen = 4096
|
||||
maxDisplayNameLen = 20
|
||||
)
|
||||
|
||||
var errPreviewFileNotFound = errors.New("preview file not found")
|
||||
|
|
@ -120,10 +122,19 @@ func (c *SessionsController) spawn(w http.ResponseWriter, r *http.Request) {
|
|||
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "PROMPT_TOO_LONG", "prompt is too long", nil)
|
||||
return
|
||||
}
|
||||
// displayName is optional at the API (the desktop new-task dialog omits it
|
||||
// and the read model falls back to the session id). `ao spawn` makes it
|
||||
// required CLI-side. When present, it is held to the same length cap here so
|
||||
// a direct API call cannot exceed it.
|
||||
displayName := strings.TrimSpace(in.DisplayName)
|
||||
if utf8.RuneCountInString(displayName) > maxDisplayNameLen {
|
||||
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "DISPLAY_NAME_TOO_LONG", "displayName must be 20 characters or fewer", nil)
|
||||
return
|
||||
}
|
||||
if in.Kind == "" {
|
||||
in.Kind = domain.KindWorker
|
||||
}
|
||||
sess, err := c.Svc.Spawn(r.Context(), ports.SpawnConfig{ProjectID: in.ProjectID, IssueID: in.IssueID, Kind: in.Kind, Harness: in.Harness, Branch: in.Branch, Prompt: in.Prompt})
|
||||
sess, err := c.Svc.Spawn(r.Context(), ports.SpawnConfig{ProjectID: in.ProjectID, IssueID: in.IssueID, Kind: in.Kind, Harness: in.Harness, Branch: in.Branch, Prompt: in.Prompt, DisplayName: displayName})
|
||||
if err != nil {
|
||||
envelope.WriteError(w, r, err)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ func (f *fakeSessionService) Spawn(_ context.Context, cfg ports.SpawnConfig) (do
|
|||
return domain.Session{}, f.spawnErr
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
s := domain.Session{SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-2"), ProjectID: cfg.ProjectID, IssueID: cfg.IssueID, Kind: cfg.Kind, Harness: cfg.Harness, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, CreatedAt: now, UpdatedAt: now}, Status: domain.StatusIdle}
|
||||
s := domain.Session{SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-2"), ProjectID: cfg.ProjectID, IssueID: cfg.IssueID, Kind: cfg.Kind, Harness: cfg.Harness, DisplayName: cfg.DisplayName, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, CreatedAt: now, UpdatedAt: now}, Status: domain.StatusIdle}
|
||||
f.sessions[s.ID] = s
|
||||
return s, nil
|
||||
}
|
||||
|
|
@ -269,7 +269,7 @@ func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) {
|
|||
t.Fatalf("list leaked prompt: %s", body)
|
||||
}
|
||||
|
||||
body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions", `{"projectId":"ao","issueId":"ISS-1","kind":"worker","harness":"codex","prompt":"fix"}`)
|
||||
body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions", `{"projectId":"ao","issueId":"ISS-1","kind":"worker","harness":"codex","prompt":"fix","displayName":"my worker"}`)
|
||||
if status != http.StatusCreated {
|
||||
t.Fatalf("POST session = %d, want 201; body=%s", status, body)
|
||||
}
|
||||
|
|
@ -280,6 +280,9 @@ func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) {
|
|||
if spawned.Session.ID != "ao-2" || spawned.Session.IssueID != "ISS-1" || spawned.Session.Harness != "codex" {
|
||||
t.Fatalf("spawned = %#v", spawned)
|
||||
}
|
||||
if spawned.Session.DisplayName != "my worker" {
|
||||
t.Fatalf("spawned displayName = %q, want %q", spawned.Session.DisplayName, "my worker")
|
||||
}
|
||||
|
||||
body, status, _ = doRequest(t, srv, "GET", "/api/v1/sessions/ao-2", "")
|
||||
if status != http.StatusOK {
|
||||
|
|
@ -679,6 +682,18 @@ func TestSessionsAPI_SpawnBranchNotFetchedReturnsTypedError(t *testing.T) {
|
|||
assertErrorCode(t, body, status, http.StatusBadRequest, "BRANCH_NOT_FETCHED")
|
||||
}
|
||||
|
||||
// TestSessionsAPI_SpawnRejectsOverlongDisplayName asserts the spawn endpoint
|
||||
// caps displayName at 20 characters even though the field itself is optional
|
||||
// (the desktop new-task dialog omits it). `ao spawn` enforces the same limit
|
||||
// CLI-side before the request is sent.
|
||||
func TestSessionsAPI_SpawnRejectsOverlongDisplayName(t *testing.T) {
|
||||
srv := newSessionTestServer(t, newFakeSessionService())
|
||||
|
||||
overlong := strings.Repeat("x", 21)
|
||||
body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions", `{"projectId":"ao","harness":"codex","displayName":"`+overlong+`"}`)
|
||||
assertErrorCode(t, body, status, http.StatusBadRequest, "DISPLAY_NAME_TOO_LONG")
|
||||
}
|
||||
|
||||
func TestSessionsAPI_RenameNotFound(t *testing.T) {
|
||||
srv := newSessionTestServer(t, newFakeSessionService())
|
||||
|
||||
|
|
|
|||
|
|
@ -164,6 +164,9 @@ var (
|
|||
// conflict markers for manual resolution. Adapters wrap this sentinel via
|
||||
// fmt.Errorf so callers can match it with errors.Is.
|
||||
ErrPreservedConflict = errors.New("workspace: preserved apply produced conflicts")
|
||||
// ErrRuntimePrerequisite reports a missing host prerequisite for the selected
|
||||
// runtime before a session can be created.
|
||||
ErrRuntimePrerequisite = errors.New("runtime: prerequisite missing")
|
||||
)
|
||||
|
||||
// WorkspaceConfig is the spec for creating or restoring a session's workspace.
|
||||
|
|
|
|||
|
|
@ -14,11 +14,11 @@ var ErrSessionNotFound = errors.New("session not found")
|
|||
type SpawnConfig struct {
|
||||
ProjectID domain.ProjectID
|
||||
IssueID domain.IssueID
|
||||
// IssueContext is optional pre-fetched tracker context for the task prompt.
|
||||
// Standing rules stay in SystemPrompt; issue facts belong to the user task.
|
||||
IssueContext string
|
||||
Kind domain.SessionKind
|
||||
Harness domain.AgentHarness
|
||||
Branch string
|
||||
Prompt string
|
||||
Kind domain.SessionKind
|
||||
Harness domain.AgentHarness
|
||||
Branch string
|
||||
Prompt string
|
||||
// DisplayName is the user-facing sidebar label. Empty falls back to the
|
||||
// session id in the read model (e.g. orchestrator sessions).
|
||||
DisplayName string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -375,8 +375,7 @@ func (e *Engine) List(ctx stdctx.Context, workerID domain.SessionID) (SessionRev
|
|||
}
|
||||
|
||||
// reviewerHarness resolves which harness reviews the worker's PR: a configured
|
||||
// reviewer wins, otherwise the worker's own harness is reused (falling back to
|
||||
// claude-code), per domain.ResolveReviewerHarness.
|
||||
// reviewer wins, otherwise claude-code is used, per domain.ResolveReviewerHarness.
|
||||
func (e *Engine) reviewerHarness(ctx stdctx.Context, worker domain.SessionRecord) (domain.ReviewerHarness, error) {
|
||||
var cfg domain.ProjectConfig
|
||||
if e.projects != nil {
|
||||
|
|
|
|||
|
|
@ -481,6 +481,8 @@ func toAPIError(err error) error {
|
|||
return apierr.Invalid("INVALID_BRANCH", err.Error(), nil)
|
||||
case errors.Is(err, ports.ErrAgentBinaryNotFound):
|
||||
return apierr.Invalid("AGENT_BINARY_NOT_FOUND", err.Error(), nil)
|
||||
case errors.Is(err, ports.ErrRuntimePrerequisite):
|
||||
return apierr.Invalid("RUNTIME_PREREQUISITE_MISSING", err.Error(), nil)
|
||||
default:
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -534,6 +534,7 @@ func TestToAPIErrorMapsWorkspaceBranchSentinels(t *testing.T) {
|
|||
{"not fetched", fmt.Errorf("spawn mer-1: workspace: %w: \"x\" has no local head", ports.ErrWorkspaceBranchNotFetched), apierr.KindInvalid, "BRANCH_NOT_FETCHED"},
|
||||
{"invalid branch", fmt.Errorf("spawn mer-1: workspace: %w: \"bad!!\" (exit 1)", ports.ErrWorkspaceBranchInvalid), apierr.KindInvalid, "INVALID_BRANCH"},
|
||||
{"agent binary not found", fmt.Errorf("spawn mer-1: %w", ports.ErrAgentBinaryNotFound), apierr.KindInvalid, "AGENT_BINARY_NOT_FOUND"},
|
||||
{"runtime prerequisite missing", fmt.Errorf("spawn: %w: tmux required on macOS/Linux but not in PATH", ports.ErrRuntimePrerequisite), apierr.KindInvalid, "RUNTIME_PREREQUISITE_MISSING"},
|
||||
{"unknown harness", fmt.Errorf("spawn: %w: %q", sessionmanager.ErrUnknownHarness, "bogus"), apierr.KindInvalid, "UNKNOWN_HARNESS"},
|
||||
{"missing harness", fmt.Errorf("spawn: %w: configure project worker.agent or pass --harness", sessionmanager.ErrMissingHarness), apierr.KindInvalid, "AGENT_REQUIRED"},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,6 +96,8 @@ type Store interface {
|
|||
// presence of any row is the marker; preserved_ref may be empty for clean
|
||||
// worktrees.
|
||||
ListSessionWorktrees(ctx context.Context, id domain.SessionID) ([]domain.SessionWorktreeRecord, error)
|
||||
// DeleteSessionWorktrees clears the "shutdown-saved" restore marker.
|
||||
DeleteSessionWorktrees(ctx context.Context, id domain.SessionID) error
|
||||
}
|
||||
|
||||
// Manager coordinates internal session spawn, restore, kill, and cleanup over
|
||||
|
|
@ -202,6 +204,10 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
|
|||
return domain.SessionRecord{}, fmt.Errorf("spawn: %w: %q", ErrUnknownHarness, cfg.Harness)
|
||||
}
|
||||
|
||||
if err := m.validateRuntimePrerequisites(); err != nil {
|
||||
return domain.SessionRecord{}, fmt.Errorf("spawn: %w", err)
|
||||
}
|
||||
|
||||
prompt, systemPrompt, err := m.buildSpawnTexts(ctx, cfg)
|
||||
if err != nil {
|
||||
return domain.SessionRecord{}, fmt.Errorf("spawn: prompt: %w", err)
|
||||
|
|
@ -242,19 +248,19 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
|
|||
// post-create commands (e.g. `pnpm install`) before the agent launches.
|
||||
if err := m.provisionWorkspace(ctx, project, ws.Path); err != nil {
|
||||
_ = m.workspace.Destroy(ctx, ws)
|
||||
m.markSpawnFailedTerminated(ctx, id)
|
||||
m.rollbackSpawnSeedRow(ctx, id)
|
||||
return domain.SessionRecord{}, fmt.Errorf("spawn %s: provision: %w", id, err)
|
||||
}
|
||||
|
||||
agent, ok := m.agents.Agent(cfg.Harness)
|
||||
if !ok {
|
||||
_ = m.workspace.Destroy(ctx, ws)
|
||||
m.markSpawnFailedTerminated(ctx, id)
|
||||
m.rollbackSpawnSeedRow(ctx, id)
|
||||
return domain.SessionRecord{}, fmt.Errorf("spawn %s: no agent adapter for harness %q", id, cfg.Harness)
|
||||
}
|
||||
if err := m.prepareWorkspace(ctx, agent, id, ws.Path); err != nil {
|
||||
_ = m.workspace.Destroy(ctx, ws)
|
||||
m.markSpawnFailedTerminated(ctx, id)
|
||||
m.rollbackSpawnSeedRow(ctx, id)
|
||||
return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err)
|
||||
}
|
||||
agentConfig := effectiveAgentConfig(cfg.Kind, project.Config)
|
||||
|
|
@ -270,7 +276,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
|
|||
})
|
||||
if err != nil {
|
||||
_ = m.workspace.Destroy(ctx, ws)
|
||||
m.markSpawnFailedTerminated(ctx, id)
|
||||
m.rollbackSpawnSeedRow(ctx, id)
|
||||
return domain.SessionRecord{}, fmt.Errorf("spawn %s: launch command: %w", id, err)
|
||||
}
|
||||
// Pre-flight: confirm argv[0] actually exists on PATH (or as an absolute
|
||||
|
|
@ -279,7 +285,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
|
|||
// unresolved binary would leak through as a "live" session that never ran.
|
||||
if err := m.validateAgentBinary(argv); err != nil {
|
||||
_ = m.workspace.Destroy(ctx, ws)
|
||||
m.markSpawnFailedTerminated(ctx, id)
|
||||
m.rollbackSpawnSeedRow(ctx, id)
|
||||
return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err)
|
||||
}
|
||||
handle, err := m.runtime.Create(ctx, ports.RuntimeConfig{
|
||||
|
|
@ -290,7 +296,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
|
|||
})
|
||||
if err != nil {
|
||||
_ = m.workspace.Destroy(ctx, ws)
|
||||
m.markSpawnFailedTerminated(ctx, id)
|
||||
m.rollbackSpawnSeedRow(ctx, id)
|
||||
return domain.SessionRecord{}, fmt.Errorf("spawn %s: runtime: %w", id, err)
|
||||
}
|
||||
|
||||
|
|
@ -450,6 +456,12 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID) (bool, error) {
|
|||
return false, fmt.Errorf("kill %s: %w", id, err)
|
||||
}
|
||||
|
||||
// Clear the restore marker so the next boot's RestoreAll cannot resurrect a
|
||||
// killed session (#2319). Best-effort: teardown below still matters.
|
||||
if err := m.store.DeleteSessionWorktrees(ctx, id); err != nil {
|
||||
m.logger.Warn("kill: delete restore marker failed", "sessionID", id, "error", err)
|
||||
}
|
||||
|
||||
// Only tear down what exists. A session may have lost its handle after a
|
||||
// crash or never acquired one if spawn failed partway.
|
||||
if handle.ID != "" {
|
||||
|
|
@ -847,6 +859,12 @@ func (m *Manager) RestoreAll(ctx context.Context) error {
|
|||
m.logger.Error("restore-all: relaunch failed", "sessionID", rec.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// One-shot: drop the consumed marker so it never outlives one restart
|
||||
// (#2319). A still-live session re-acquires it at the next quit.
|
||||
if err := m.store.DeleteSessionWorktrees(ctx, rec.ID); err != nil {
|
||||
m.logger.Warn("restore-all: delete restore marker failed", "sessionID", rec.ID, "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -928,13 +946,14 @@ func (m *Manager) cleanupRecords(ctx context.Context, project domain.ProjectID)
|
|||
|
||||
func seedRecord(cfg ports.SpawnConfig, now time.Time) domain.SessionRecord {
|
||||
return domain.SessionRecord{
|
||||
ProjectID: cfg.ProjectID,
|
||||
IssueID: cfg.IssueID,
|
||||
Kind: cfg.Kind,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Harness: cfg.Harness,
|
||||
Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now},
|
||||
ProjectID: cfg.ProjectID,
|
||||
IssueID: cfg.IssueID,
|
||||
Kind: cfg.Kind,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Harness: cfg.Harness,
|
||||
DisplayName: cfg.DisplayName,
|
||||
Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1300,6 +1319,16 @@ func (m *Manager) validateAgentBinary(argv []string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) validateRuntimePrerequisites() error {
|
||||
if runtime.GOOS == "windows" {
|
||||
return nil
|
||||
}
|
||||
if path, err := m.lookPath("tmux"); err != nil || path == "" {
|
||||
return fmt.Errorf("%w: tmux required on macOS/Linux but not in PATH", ports.ErrRuntimePrerequisite)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runtimeHandle(meta domain.SessionMetadata) ports.RuntimeHandle {
|
||||
return ports.RuntimeHandle{ID: meta.RuntimeHandleID}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -112,6 +113,10 @@ func (f *fakeStore) UpsertSessionWorktree(_ context.Context, row domain.SessionW
|
|||
func (f *fakeStore) ListSessionWorktrees(_ context.Context, id domain.SessionID) ([]domain.SessionWorktreeRecord, error) {
|
||||
return f.worktrees[id], nil
|
||||
}
|
||||
func (f *fakeStore) DeleteSessionWorktrees(_ context.Context, id domain.SessionID) error {
|
||||
delete(f.worktrees, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeLCM struct {
|
||||
store *fakeStore
|
||||
|
|
@ -465,8 +470,8 @@ func TestSpawn_RollsBackOnRuntimeFailure(t *testing.T) {
|
|||
if ws.destroyed != 1 {
|
||||
t.Fatal("workspace should roll back")
|
||||
}
|
||||
if !st.sessions["mer-1"].IsTerminated {
|
||||
t.Fatal("orphaned spawn should be terminated")
|
||||
if rec, present := st.sessions["mer-1"]; present {
|
||||
t.Fatalf("seed row must be deleted before a runtime handle is live, got %+v", rec)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -569,6 +574,29 @@ func TestKill_OtherWorkspaceErrorStillFails(t *testing.T) {
|
|||
t.Fatalf("kill err = %v, want workspace error surfaced", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKill_DeletesRestoreMarker covers issue #2319 (a): a user kill is explicit
|
||||
// terminal intent and must delete the session_worktrees "shutdown-saved" marker.
|
||||
// A session that carried a marker (e.g. it survived a prior reopen cycle) and is
|
||||
// then killed must not keep that marker, or the next boot's RestoreAll would
|
||||
// resurrect it.
|
||||
func TestKill_DeletesRestoreMarker(t *testing.T) {
|
||||
m, st, _, _ := newManager()
|
||||
st.sessions["mer-1"] = mkLive("mer-1")
|
||||
// The session carries a leftover shutdown-saved marker from a prior cycle.
|
||||
st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__"}}
|
||||
|
||||
if _, err := m.Kill(ctx, "mer-1"); err != nil {
|
||||
t.Fatalf("kill err = %v", err)
|
||||
}
|
||||
rows, err := st.ListSessionWorktrees(ctx, "mer-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("kill must delete the restore marker, got %d rows", len(rows))
|
||||
}
|
||||
}
|
||||
func TestRestore_ReopensTerminal(t *testing.T) {
|
||||
m, st, rt, _ := newManager()
|
||||
seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"})
|
||||
|
|
@ -1248,6 +1276,9 @@ func TestSpawn_RejectsMissingAgentBinary(t *testing.T) {
|
|||
rt := &fakeRuntime{}
|
||||
ws := &fakeWorkspace{}
|
||||
notFound := func(name string) (string, error) {
|
||||
if name == "tmux" {
|
||||
return "/bin/tmux", nil
|
||||
}
|
||||
return "", fmt.Errorf("exec: %q: not found", name)
|
||||
}
|
||||
m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: notFound})
|
||||
|
|
@ -1262,8 +1293,39 @@ func TestSpawn_RejectsMissingAgentBinary(t *testing.T) {
|
|||
if ws.destroyed != 1 {
|
||||
t.Fatal("workspace must be torn down when the pre-launch binary check fails")
|
||||
}
|
||||
if !st.sessions["mer-1"].IsTerminated {
|
||||
t.Fatal("the orphan row should be marked terminated after the failed spawn")
|
||||
if rec, present := st.sessions["mer-1"]; present {
|
||||
t.Fatalf("seed row must be deleted before a runtime handle is live, got %+v", rec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawn_RejectsMissingTmuxBeforeSessionRow(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows uses ConPTY, not tmux")
|
||||
}
|
||||
st := newFakeStore()
|
||||
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
||||
rt := &fakeRuntime{}
|
||||
ws := &fakeWorkspace{}
|
||||
lookPath := func(name string) (string, error) {
|
||||
if name == "tmux" {
|
||||
return "", fmt.Errorf("exec: %q: not found", name)
|
||||
}
|
||||
return "/bin/true", nil
|
||||
}
|
||||
m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
|
||||
|
||||
_, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker})
|
||||
if !errors.Is(err, ports.ErrRuntimePrerequisite) || !strings.Contains(err.Error(), "tmux required") {
|
||||
t.Fatalf("err = %v, want missing tmux prerequisite", err)
|
||||
}
|
||||
if len(st.sessions) != 0 {
|
||||
t.Fatalf("no session row should be created before runtime prerequisites pass, got %d", len(st.sessions))
|
||||
}
|
||||
if ws.lastCfg.SessionID != "" || ws.destroyed != 0 {
|
||||
t.Fatal("workspace must not be created when tmux is missing")
|
||||
}
|
||||
if rt.created != 0 {
|
||||
t.Fatal("runtime must not be created when tmux is missing")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1693,6 +1755,81 @@ func TestRestoreAll_SkipsSessionsKilledBeforeShutdown(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestRestoreAll_DeletesMarkerAfterRelaunch covers issue #2319 (b): the
|
||||
// shutdown-saved marker is one-shot. After RestoreAll relaunches a session, its
|
||||
// session_worktrees marker is deleted, so a second RestoreAll (with no fresh
|
||||
// marker) does NOT relaunch it again.
|
||||
func TestRestoreAll_DeletesMarkerAfterRelaunch(t *testing.T) {
|
||||
m, st, rt, _ := newLifecycleManager()
|
||||
|
||||
st.sessions["mer-1"] = domain.SessionRecord{
|
||||
ID: "mer-1",
|
||||
ProjectID: "mer",
|
||||
Kind: domain.KindWorker,
|
||||
Harness: domain.HarnessClaudeCode,
|
||||
IsTerminated: true,
|
||||
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"},
|
||||
Activity: domain.Activity{State: domain.ActivityExited},
|
||||
}
|
||||
st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__"}}
|
||||
|
||||
if err := m.RestoreAll(ctx); err != nil {
|
||||
t.Fatalf("RestoreAll err = %v", err)
|
||||
}
|
||||
if rt.created != 1 {
|
||||
t.Fatalf("first RestoreAll must relaunch once, runtime.Create called %d times", rt.created)
|
||||
}
|
||||
rows, err := st.ListSessionWorktrees(ctx, "mer-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("RestoreAll must delete the one-shot marker, got %d rows", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreAll_KilledSessionNotResurrectedOnSecondBoot covers issue #2319 (c),
|
||||
// the killed-session-resurrection scenario. A terminated session WITH a marker
|
||||
// is relaunched exactly once; on a second RestoreAll (no new marker) it stays
|
||||
// terminated and is not relaunched again.
|
||||
func TestRestoreAll_KilledSessionNotResurrectedOnSecondBoot(t *testing.T) {
|
||||
m, st, rt, _ := newLifecycleManager()
|
||||
|
||||
st.sessions["mer-1"] = domain.SessionRecord{
|
||||
ID: "mer-1",
|
||||
ProjectID: "mer",
|
||||
Kind: domain.KindWorker,
|
||||
Harness: domain.HarnessClaudeCode,
|
||||
IsTerminated: true,
|
||||
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"},
|
||||
Activity: domain.Activity{State: domain.ActivityExited},
|
||||
}
|
||||
st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__"}}
|
||||
|
||||
// First boot: marker present, session relaunches once.
|
||||
if err := m.RestoreAll(ctx); err != nil {
|
||||
t.Fatalf("first RestoreAll err = %v", err)
|
||||
}
|
||||
if rt.created != 1 {
|
||||
t.Fatalf("first RestoreAll must relaunch once, runtime.Create called %d times", rt.created)
|
||||
}
|
||||
|
||||
// Simulate the user killing the relaunched session before the next quit, so
|
||||
// it has no fresh marker, then a second boot.
|
||||
if _, err := m.Kill(ctx, "mer-1"); err != nil {
|
||||
t.Fatalf("kill err = %v", err)
|
||||
}
|
||||
if err := m.RestoreAll(ctx); err != nil {
|
||||
t.Fatalf("second RestoreAll err = %v", err)
|
||||
}
|
||||
if rt.created != 1 {
|
||||
t.Fatalf("killed session must NOT be resurrected on second boot, runtime.Create total = %d, want 1", rt.created)
|
||||
}
|
||||
if !st.sessions["mer-1"].IsTerminated {
|
||||
t.Error("killed session must remain terminated after second RestoreAll")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreAll_AppliesPreservedRef: when the session_worktrees row has a
|
||||
// non-empty preserved_ref, RestoreAll calls ApplyPreserved after workspace
|
||||
// restore but before relaunching.
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 238 KiB After Width: | Height: | Size: 224 KiB |
|
|
@ -883,6 +883,7 @@ export interface components {
|
|||
};
|
||||
SpawnSessionRequest: {
|
||||
branch?: string;
|
||||
displayName?: string;
|
||||
/** @enum {string} */
|
||||
harness?: "claude-code" | "codex" | "aider" | "opencode" | "grok" | "droid" | "amp" | "agy" | "crush" | "cursor" | "qwen" | "copilot" | "goose" | "auggie" | "continue" | "devin" | "cline" | "kimi" | "kiro" | "kilocode" | "vibe" | "pi" | "autohand";
|
||||
issueId?: string;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
dialog,
|
||||
ipcMain,
|
||||
net,
|
||||
nativeImage,
|
||||
Notification as ElectronNotification,
|
||||
protocol,
|
||||
shell,
|
||||
|
|
@ -156,6 +157,16 @@ function windowIconPath(): string | undefined {
|
|||
return existsSync(candidate) ? candidate : undefined;
|
||||
}
|
||||
|
||||
function applyRuntimeAppIcon(): void {
|
||||
if (process.platform !== "darwin") return;
|
||||
const iconPath = windowIconPath();
|
||||
if (!iconPath) return;
|
||||
const icon = nativeImage.createFromPath(iconPath);
|
||||
if (!icon.isEmpty()) {
|
||||
app.dock.setIcon(icon);
|
||||
}
|
||||
}
|
||||
|
||||
function setDaemonStatus(nextStatus: DaemonStatus): void {
|
||||
daemonStatus = nextStatus;
|
||||
mainWindow?.webContents.send("daemon:status", daemonStatus);
|
||||
|
|
@ -952,6 +963,7 @@ app.whenReady().then(async () => {
|
|||
}
|
||||
|
||||
registerRendererProtocol();
|
||||
applyRuntimeAppIcon();
|
||||
createWindow();
|
||||
void startDaemon();
|
||||
initAutoUpdates();
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NewTaskDialog } from "./NewTaskDialog";
|
||||
|
||||
const { postMock } = vi.hoisted(() => ({
|
||||
const { getMock, postMock } = vi.hoisted(() => ({
|
||||
getMock: vi.fn(),
|
||||
postMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/api-client", () => ({
|
||||
apiClient: {
|
||||
POST: postMock,
|
||||
GET: (...args: unknown[]) => getMock(...args),
|
||||
POST: (...args: unknown[]) => postMock(...args),
|
||||
},
|
||||
apiErrorMessage: (error: unknown, fallback = "Request failed") => {
|
||||
if (typeof error === "object" && error !== null && "message" in error) {
|
||||
|
|
@ -19,27 +22,48 @@ vi.mock("../lib/api-client", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
function renderDialog() {
|
||||
const onCreated = vi.fn();
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<NewTaskDialog open projectId="proj-1" onCreated={onCreated} onOpenChange={onOpenChange} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { onCreated, onOpenChange };
|
||||
}
|
||||
|
||||
function spawnBody() {
|
||||
return (postMock.mock.calls[0][1] as { body: Record<string, unknown> }).body;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
postMock.mockReset();
|
||||
postMock.mockResolvedValue({ data: { session: { id: "task-1" } }, error: undefined });
|
||||
getMock.mockReset().mockResolvedValue({
|
||||
data: { status: "ok", project: { id: "proj-1", config: { worker: { agent: "claude-code" } } } },
|
||||
error: undefined,
|
||||
});
|
||||
postMock.mockReset().mockResolvedValue({ data: { session: { id: "task-1" } }, error: undefined });
|
||||
});
|
||||
|
||||
describe("NewTaskDialog", () => {
|
||||
it("starts a worker task with the entered title and brief", async () => {
|
||||
const onCreated = vi.fn();
|
||||
const onOpenChange = vi.fn();
|
||||
render(<NewTaskDialog open projectId="proj-1" onCreated={onCreated} onOpenChange={onOpenChange} />);
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
await userEvent.type(screen.getByLabelText("Title"), "Fix fallback renderer");
|
||||
await userEvent.type(screen.getByLabelText("Brief"), "Restore the fallback renderer after WebGL init fails.");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Start task" }));
|
||||
describe("NewTaskDialog", () => {
|
||||
it("preselects the project's default agent and omits harness so the daemon applies it", async () => {
|
||||
const { onCreated, onOpenChange } = renderDialog();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await screen.findByText("claude-code");
|
||||
|
||||
await user.type(screen.getByLabelText("Title"), "Fix fallback renderer");
|
||||
await user.type(screen.getByLabelText("Brief"), "Restore the fallback renderer after WebGL init fails.");
|
||||
await user.click(screen.getByRole("button", { name: "Start task" }));
|
||||
|
||||
await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1));
|
||||
expect(postMock).toHaveBeenCalledWith("/api/v1/sessions", {
|
||||
body: {
|
||||
projectId: "proj-1",
|
||||
kind: "worker",
|
||||
harness: "codex",
|
||||
harness: undefined,
|
||||
issueId: "Fix fallback renderer",
|
||||
prompt: "Restore the fallback renderer after WebGL init fails.",
|
||||
branch: undefined,
|
||||
|
|
@ -49,10 +73,28 @@ describe("NewTaskDialog", () => {
|
|||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("requires both title and brief", async () => {
|
||||
render(<NewTaskDialog open projectId="proj-1" onCreated={vi.fn()} onOpenChange={vi.fn()} />);
|
||||
it("sends the chosen harness when the user overrides the default, including agents beyond the legacy four", async () => {
|
||||
renderDialog();
|
||||
const user = userEvent.setup();
|
||||
await screen.findByText("claude-code");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Start task" }));
|
||||
await user.type(screen.getByLabelText("Title"), "T");
|
||||
await user.type(screen.getByLabelText("Brief"), "B");
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: "Agent" }));
|
||||
await user.click(await screen.findByRole("option", { name: "cursor" }));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Start task" }));
|
||||
|
||||
await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1));
|
||||
expect(spawnBody().harness).toBe("cursor");
|
||||
});
|
||||
|
||||
it("requires both title and brief", async () => {
|
||||
renderDialog();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Start task" }));
|
||||
|
||||
expect(await screen.findByText("Title and brief are required.")).toBeInTheDocument();
|
||||
expect(postMock).not.toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2, X } from "lucide-react";
|
||||
import { type FormEvent, useEffect, useId, useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { RequiredAgentField } from "./CreateProjectAgentSheet";
|
||||
import type { components } from "../../api/schema";
|
||||
import { apiClient, apiErrorMessage } from "../lib/api-client";
|
||||
import type { AgentProvider } from "../types/workspace";
|
||||
|
||||
type Project = components["schemas"]["Project"];
|
||||
|
||||
type NewTaskDialogProps = {
|
||||
open: boolean;
|
||||
projectId?: string;
|
||||
|
|
@ -14,35 +18,51 @@ type NewTaskDialogProps = {
|
|||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const AGENTS: Array<{ value: AgentProvider; label: string }> = [
|
||||
{ value: "codex", label: "Codex" },
|
||||
{ value: "claude-code", label: "Claude Code" },
|
||||
{ value: "opencode", label: "OpenCode" },
|
||||
{ value: "aider", label: "Aider" },
|
||||
];
|
||||
|
||||
export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewTaskDialogProps) {
|
||||
const titleId = useId();
|
||||
const promptId = useId();
|
||||
const branchId = useId();
|
||||
const agentId = useId();
|
||||
const [title, setTitle] = useState("");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [branch, setBranch] = useState("");
|
||||
const [agent, setAgent] = useState<AgentProvider>("codex");
|
||||
const [agent, setAgent] = useState("");
|
||||
const [agentTouched, setAgentTouched] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
|
||||
const projectQuery = useQuery({
|
||||
queryKey: ["project", projectId],
|
||||
enabled: open && Boolean(projectId),
|
||||
queryFn: async () => {
|
||||
const { data, error: apiError } = await apiClient.GET("/api/v1/projects/{id}", {
|
||||
params: { path: { id: projectId as string } },
|
||||
});
|
||||
if (apiError) throw new Error(apiErrorMessage(apiError));
|
||||
if (data?.status !== "ok") throw new Error("Project config is unavailable.");
|
||||
return data.project as Project;
|
||||
},
|
||||
});
|
||||
const defaultWorkerAgent = projectQuery.data?.config?.worker?.agent ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setTitle("");
|
||||
setPrompt("");
|
||||
setBranch("");
|
||||
setAgent("codex");
|
||||
setAgent("");
|
||||
setAgentTouched(false);
|
||||
setError(undefined);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !agentTouched) {
|
||||
setAgent(defaultWorkerAgent);
|
||||
}
|
||||
}, [open, agentTouched, defaultWorkerAgent]);
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!projectId || isSubmitting) return;
|
||||
|
|
@ -62,7 +82,7 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT
|
|||
body: {
|
||||
projectId,
|
||||
kind: "worker",
|
||||
harness: agent,
|
||||
harness: agentTouched && agent ? (agent as AgentProvider) : undefined,
|
||||
issueId: cleanTitle,
|
||||
prompt: cleanPrompt,
|
||||
branch: cleanBranch || undefined,
|
||||
|
|
@ -130,21 +150,16 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT
|
|||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_1fr]">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px] font-medium text-muted-foreground">Agent</label>
|
||||
<Select value={agent} onValueChange={(value) => setAgent(value as AgentProvider)}>
|
||||
<SelectTrigger className="h-8 w-full text-[13px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AGENTS.map((entry) => (
|
||||
<SelectItem key={entry.value} value={entry.value}>
|
||||
{entry.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<RequiredAgentField
|
||||
id={agentId}
|
||||
label="Agent"
|
||||
placeholder="Project default"
|
||||
value={agent}
|
||||
onChange={(value) => {
|
||||
setAgent(value);
|
||||
setAgentTouched(true);
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px] font-medium text-muted-foreground" htmlFor={branchId}>
|
||||
Branch
|
||||
|
|
|
|||
|
|
@ -270,7 +270,7 @@ function ReviewerSelect({ id, value, onChange }: { id: string; value: string; on
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">Project default</SelectItem>
|
||||
<SelectItem value="__default__">claude-code (default)</SelectItem>
|
||||
{REVIEWER_OPTIONS.map((reviewer) => (
|
||||
<SelectItem key={reviewer} value={reviewer}>
|
||||
{reviewer}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,11 @@ const session = (prs: PullRequestFacts[]): WorkspaceSession => ({
|
|||
prs,
|
||||
});
|
||||
|
||||
const sessionWithProvider = (prs: PullRequestFacts[], provider: WorkspaceSession["provider"]): WorkspaceSession => ({
|
||||
...session(prs),
|
||||
provider,
|
||||
});
|
||||
|
||||
function renderWithQuery(children: ReactNode) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
|
|
@ -199,6 +204,36 @@ describe("SessionInspector reviews tab", () => {
|
|||
expect(onOpenReviewerTerminal).toHaveBeenCalledWith({ handleId: "reviewer-pane", harness: "codex" });
|
||||
});
|
||||
|
||||
it("shows claude-code as the default reviewer before a run exists", async () => {
|
||||
getMock.mockImplementation(async (path: string) => {
|
||||
if (path === "/api/v1/sessions/{sessionId}/reviews") {
|
||||
return { data: { reviewerHandleId: "", reviews: [] } };
|
||||
}
|
||||
if (path === "/api/v1/projects/{id}") {
|
||||
return {
|
||||
data: {
|
||||
status: "ok",
|
||||
project: {
|
||||
id: "ws-1",
|
||||
kind: "git",
|
||||
name: "my-app",
|
||||
path: "/repo",
|
||||
repo: "my-app",
|
||||
defaultBranch: "main",
|
||||
config: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return { data: undefined };
|
||||
});
|
||||
|
||||
renderWithQuery(<SessionInspector session={sessionWithProvider([pr(3, "open")], "codex")} />);
|
||||
await openReviewsTab();
|
||||
|
||||
expect(await screen.findByText("claude-code")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows eligible and up-to-date PR review rows", async () => {
|
||||
mockCommonGets([approvedReview], "reviewer-pane", [
|
||||
reviewState(3, "needs_review", "abc123"),
|
||||
|
|
|
|||
|
|
@ -483,7 +483,7 @@ function ReviewPanel({
|
|||
}
|
||||
|
||||
const latest = reviewStates.find((review) => review.latestRun)?.latestRun;
|
||||
const harness = latest?.harness || config?.reviewers?.[0]?.harness || session.provider || "reviewer";
|
||||
const harness = latest?.harness || config?.reviewers?.[0]?.harness || "claude-code";
|
||||
const terminalEnabled = Boolean(reviewerHandleId && onOpenTerminal);
|
||||
const aggregateVerdict = sessionReviewVerdict(reviewStates);
|
||||
const runAction = reviewSessionRunAction(reviewStates, isTriggering);
|
||||
|
|
|
|||
|
|
@ -4,13 +4,16 @@ import { render, screen, waitFor } from "@testing-library/react";
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import type { WorkspaceSummary } from "../types/workspace";
|
||||
import type { WorkspaceSession, WorkspaceSummary } from "../types/workspace";
|
||||
|
||||
const { navigateMock, mockParams } = vi.hoisted(() => ({
|
||||
const { navigateMock, mockParams, renameSessionMock } = vi.hoisted(() => ({
|
||||
navigateMock: vi.fn(),
|
||||
mockParams: { projectId: undefined as string | undefined },
|
||||
renameSessionMock: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/rename-session", () => ({ renameSession: renameSessionMock }));
|
||||
|
||||
vi.mock("@tanstack/react-router", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
|
||||
return {
|
||||
|
|
@ -29,15 +32,30 @@ const workspace: WorkspaceSummary = {
|
|||
sessions: [],
|
||||
};
|
||||
|
||||
const session: WorkspaceSession = {
|
||||
id: "proj-1-1",
|
||||
workspaceId: "proj-1",
|
||||
workspaceName: "Project One",
|
||||
title: "fix login",
|
||||
provider: "claude-code",
|
||||
kind: "worker",
|
||||
branch: "session/proj-1-1",
|
||||
status: "working",
|
||||
updatedAt: "2026-06-30T00:00:00Z",
|
||||
prs: [],
|
||||
};
|
||||
|
||||
type CreateProjectHandler = (input: { path: string; workerAgent: string; orchestratorAgent: string }) => Promise<void>;
|
||||
type RemoveProjectHandler = (projectId: string) => Promise<void>;
|
||||
|
||||
function renderSidebar({
|
||||
onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler,
|
||||
onRemoveProject = vi.fn().mockResolvedValue(undefined) as RemoveProjectHandler,
|
||||
workspaces = [workspace],
|
||||
}: {
|
||||
onCreateProject?: CreateProjectHandler;
|
||||
onRemoveProject?: RemoveProjectHandler;
|
||||
workspaces?: WorkspaceSummary[];
|
||||
} = {}) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
|
|
@ -49,7 +67,7 @@ function renderSidebar({
|
|||
daemonStatus={{ state: "running" }}
|
||||
onCreateProject={onCreateProject}
|
||||
onRemoveProject={onRemoveProject}
|
||||
workspaces={[workspace]}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</QueryClientProvider>,
|
||||
|
|
@ -64,6 +82,7 @@ async function chooseOption(trigger: HTMLElement, optionName: string) {
|
|||
|
||||
beforeEach(() => {
|
||||
navigateMock.mockReset();
|
||||
renameSessionMock.mockReset().mockResolvedValue(undefined);
|
||||
mockParams.projectId = undefined;
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
vi.spyOn(window, "alert").mockImplementation(() => undefined);
|
||||
|
|
@ -161,6 +180,42 @@ describe("Sidebar", () => {
|
|||
expect(navigateMock).toHaveBeenCalledWith({ to: "/settings" });
|
||||
});
|
||||
|
||||
it("renames a session inline and persists via the daemon", async () => {
|
||||
const user = userEvent.setup();
|
||||
const workspaceWithSession = { ...workspace, sessions: [session] };
|
||||
renderSidebar({ workspaces: [workspaceWithSession] });
|
||||
|
||||
await user.click(screen.getByLabelText("Rename fix login"));
|
||||
const input = screen.getByLabelText("Rename fix login");
|
||||
await user.clear(input);
|
||||
await user.type(input, "polish login{Enter}");
|
||||
|
||||
await waitFor(() => expect(renameSessionMock).toHaveBeenCalledWith("proj-1-1", "polish login"));
|
||||
});
|
||||
|
||||
it("caps the inline rename input at 20 characters", async () => {
|
||||
const user = userEvent.setup();
|
||||
const workspaceWithSession = { ...workspace, sessions: [session] };
|
||||
renderSidebar({ workspaces: [workspaceWithSession] });
|
||||
|
||||
await user.click(screen.getByLabelText("Rename fix login"));
|
||||
expect(screen.getByLabelText("Rename fix login")).toHaveAttribute("maxlength", "20");
|
||||
});
|
||||
|
||||
it("cancels the inline rename on Escape without calling the daemon", async () => {
|
||||
const user = userEvent.setup();
|
||||
const workspaceWithSession = { ...workspace, sessions: [session] };
|
||||
renderSidebar({ workspaces: [workspaceWithSession] });
|
||||
|
||||
await user.click(screen.getByLabelText("Rename fix login"));
|
||||
const input = screen.getByLabelText("Rename fix login");
|
||||
await user.clear(input);
|
||||
await user.type(input, "discard me{Escape}");
|
||||
|
||||
expect(renameSessionMock).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText("Open fix login")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("always shows action icons and reserves padding for them", () => {
|
||||
renderSidebar();
|
||||
|
||||
|
|
|
|||
|
|
@ -6,13 +6,14 @@ import {
|
|||
LayoutDashboard,
|
||||
Moon,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
Sun,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
attentionZone,
|
||||
isOrchestratorSession,
|
||||
|
|
@ -24,6 +25,7 @@ import {
|
|||
import { aoBridge } from "../lib/bridge";
|
||||
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
|
||||
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
|
||||
import { renameSession } from "../lib/rename-session";
|
||||
import { useEventsConnection } from "../hooks/useEventsConnection";
|
||||
import { useResizable } from "../hooks/useResizable";
|
||||
import {
|
||||
|
|
@ -70,6 +72,10 @@ const noDragStyle = isMac ? ({ WebkitAppRegion: "no-drag" } as React.CSSProperti
|
|||
const HOVER_ACTION_CLASS =
|
||||
"grid size-5 shrink-0 place-items-center rounded-md text-passive transition-colors hover:bg-interactive-hover hover:text-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-interactive-hover data-[state=open]:text-foreground [&_svg]:size-[15px]";
|
||||
|
||||
// Mirrors the daemon's display-name cap (maxDisplayNameLen) and the spawn
|
||||
// `--name` flag, so inline edits never round-trip a value the API would reject.
|
||||
const MAX_DISPLAY_NAME_LEN = 20;
|
||||
|
||||
type SidebarProps = {
|
||||
daemonStatus: { state: string; message?: string };
|
||||
underTopbar?: boolean;
|
||||
|
|
@ -562,40 +568,122 @@ function ProjectItem({
|
|||
sessions read as children without adding a persistent guide rail. */}
|
||||
{expanded && sessions.length > 0 && (
|
||||
<SidebarMenuSub className="mx-0 ml-[18px] translate-x-0 gap-0 border-l-0 px-0 py-1 pl-2.5">
|
||||
{sessions.map((session) => {
|
||||
const active = selection.activeSessionId === session.id;
|
||||
return (
|
||||
<SidebarMenuSubItem key={session.id}>
|
||||
<button
|
||||
aria-current={active ? "page" : undefined}
|
||||
aria-label={`Open ${session.title}`}
|
||||
className={cn(
|
||||
"relative flex h-auto w-full items-center gap-[9px] rounded-[4px] py-[5px] pl-2.5 pr-1.5 text-left outline-hidden transition-[color]",
|
||||
"before:absolute before:top-1.5 before:bottom-1.5 before:left-0 before:w-px before:rounded-full before:bg-transparent",
|
||||
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
active && "text-foreground before:bg-accent",
|
||||
)}
|
||||
onClick={() => selection.goSession(workspace.id, session.id)}
|
||||
type="button"
|
||||
>
|
||||
<SessionDot session={session} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn("block truncate text-[12px]", active ? "text-foreground" : "text-muted-foreground")}
|
||||
>
|
||||
{session.title}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</SidebarMenuSubItem>
|
||||
);
|
||||
})}
|
||||
{sessions.map((session) => (
|
||||
<SessionRow
|
||||
key={session.id}
|
||||
session={session}
|
||||
active={selection.activeSessionId === session.id}
|
||||
onOpen={() => selection.goSession(workspace.id, session.id)}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
// One worker-session row. Reads as a link by default; a hover-revealed pencil
|
||||
// flips the label into an inline input (Enter/blur saves, Escape cancels) that
|
||||
// persists through the daemon rename endpoint, so the new name survives reload.
|
||||
function SessionRow({ session, active, onOpen }: { session: WorkspaceSession; active: boolean; onOpen: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(session.title);
|
||||
// Escape must not be swallowed by the blur-to-save path: the keydown handler
|
||||
// blurs the input, so it flags a cancel here for onBlur to honour.
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
const startEditing = () => {
|
||||
setDraft(session.title);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const commit = async () => {
|
||||
if (cancelledRef.current) {
|
||||
cancelledRef.current = false;
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
setIsEditing(false);
|
||||
const name = draft.trim();
|
||||
if (!name || name === session.title) return;
|
||||
try {
|
||||
await renameSession(session.id, name);
|
||||
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
|
||||
} catch (err) {
|
||||
console.error("Failed to rename session:", err);
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<SidebarMenuSubItem>
|
||||
<div className="relative flex h-auto w-full items-center gap-[9px] rounded-[4px] py-[5px] pl-2.5 pr-1.5">
|
||||
<SessionDot session={session} />
|
||||
<input
|
||||
aria-label={`Rename ${session.title}`}
|
||||
autoFocus
|
||||
className="min-w-0 flex-1 rounded-[3px] border border-accent bg-transparent px-1 py-px text-[12px] text-foreground outline-none focus-visible:ring-1 focus-visible:ring-accent"
|
||||
maxLength={MAX_DISPLAY_NAME_LEN}
|
||||
onBlur={() => void commit()}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelledRef.current = true;
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
value={draft}
|
||||
/>
|
||||
</div>
|
||||
</SidebarMenuSubItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenuSubItem>
|
||||
<button
|
||||
aria-current={active ? "page" : undefined}
|
||||
aria-label={`Open ${session.title}`}
|
||||
className={cn(
|
||||
"relative flex h-auto w-full items-center gap-[9px] rounded-[4px] py-[5px] pl-2.5 pr-7 text-left outline-hidden transition-[color]",
|
||||
"before:absolute before:top-1.5 before:bottom-1.5 before:left-0 before:w-px before:rounded-full before:bg-transparent",
|
||||
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
active && "text-foreground before:bg-accent",
|
||||
)}
|
||||
onClick={onOpen}
|
||||
type="button"
|
||||
>
|
||||
<SessionDot session={session} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={cn("block truncate text-[12px]", active ? "text-foreground" : "text-muted-foreground")}>
|
||||
{session.title}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{/* Pencil reveals on row hover/focus (named group on SidebarMenuSubItem);
|
||||
it sits beside the row button rather than nested inside it. */}
|
||||
<button
|
||||
aria-label={`Rename ${session.title}`}
|
||||
className={cn(
|
||||
HOVER_ACTION_CLASS,
|
||||
"absolute top-1/2 right-1 -translate-y-1/2 opacity-0",
|
||||
"group-focus-within/menu-sub-item:opacity-100 group-hover/menu-sub-item:opacity-100",
|
||||
)}
|
||||
onClick={startEditing}
|
||||
type="button"
|
||||
>
|
||||
<Pencil aria-hidden="true" />
|
||||
</button>
|
||||
</SidebarMenuSubItem>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateProjectButton({ onCreateProject }: Pick<SidebarProps, "onCreateProject">) {
|
||||
return (
|
||||
<CreateProjectFlow onCreateProject={onCreateProject}>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import { apiClient, apiErrorMessage } from "./api-client";
|
||||
|
||||
/** Update a session's display name via the daemon (PATCH /sessions/{id}). The
|
||||
* daemon enforces the same 20-character limit as the spawn `--name` flag. */
|
||||
export async function renameSession(sessionId: string, displayName: string): Promise<void> {
|
||||
const { error, response } = await apiClient.PATCH("/api/v1/sessions/{sessionId}", {
|
||||
params: { path: { sessionId } },
|
||||
body: { displayName },
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(apiErrorMessage(error, `Failed to rename session (${response.status})`));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue