From f3397b4ad77bc827abfc9b7f62363a021bf51fb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Thu, 14 May 2026 21:26:09 +0100 Subject: [PATCH] feat: docker and sprites executor improvements (#738) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(orchestrator): generalize task environment reuse across executor types Worktree, container, and sandbox IDs now flow uniformly from a persisted TaskEnvironment back into LaunchAgentRequest through a single helper. This gives Sprites session-to-session reuse the same first-class treatment that Docker already gained from the GitHub-token-injection PR, and lets a fallback launch refresh the environment row instead of leaving stale IDs in the DB. - applyExistingEnvironment propagates WorktreeID, ContainerID (via metadata), and SandboxID (as sprite_name metadata) onto the launch request - persistTaskEnvironment refreshes those IDs when the executor ends up creating new resources (e.g. the previous container was pruned) - extractSandboxID lifts the sprite identifier out of executor response metadata so it can be persisted on the TaskEnvironment row * feat(backend): add task-level reset environment action Users sometimes need a clean slate — broken packages, stale caches, a container that drifted into a bad state. Reset Environment is the explicit escape hatch: tear down the recorded container, sandbox, and/or worktree for a task and delete the TaskEnvironment row so the next session starts fresh. - service.ResetTaskEnvironment(taskID, ResetOptions) is the entry point. ResetOptions.PushBranch optionally git-pushes the workspace branch before teardown so committed work isn't dropped on the floor; a failed push aborts the reset and leaves the environment intact. - A new EnvironmentDestroyer interface in the task service hides the per-resource teardown behind a single seam, implemented in cmd/kandev by delegating to lifecycle.Manager (containers, sprites) and worktree.Manager (worktrees, branch preserved). - lifecycle.Manager.DestroyContainer / DestroySandbox dispatch to the Docker and Sprites executor backends without leaking model types into the lifecycle package. - Reset is blocked while any session on the task still has an ExecutorRunning row (returns ErrSessionRunning → HTTP 409). Per-resource teardown is best-effort but the DB row is only removed once everything is confirmed gone or already missing, so a partial failure is retryable by clicking Reset again. - Endpoint: POST /api/v1/tasks/:id/environment/reset. * feat(web): add executor settings popover with reset environment action A new button on the task top bar opens a popover showing the active TaskEnvironment — executor type, status, container/sprite/worktree identifiers, and a copy-ready 'docker exec' command for local Docker. The popover hosts the destructive Reset Environment action so users have one obvious place to inspect and manage the environment without cluttering the New Session dialog. The reset confirmation dialog requires explicit acknowledgement that uncommitted changes will be lost. When the environment has a host-side worktree it offers an opt-in 'push branch before resetting' checkbox so committed work can be pushed first; the backend aborts the reset on push failure. - task-environment-api.ts: fetchTaskEnvironment + resetTaskEnvironment - executor-settings-button.tsx: popover trigger + identity panel - task-reset-env-confirm-dialog.tsx: destructive confirm with push opt-in - task-top-bar.tsx: wire the new button into the right-side controls - e2e: replace fresh-env-toggle test with reset-from-popover flow * fix(docker): expand {workspace} placeholder in container WorkingDir The agent runtime config carries `WorkingDir: "{workspace}"` for most ACP agents. Mount sources were already expanded via expandMountSource, but WorkingDir was passed through verbatim, so Docker rejected new containers with: Error response from daemon: the working directory '{workspace}' is invalid, it needs to be an absolute path Run the same expansion on WorkingDir, defaulting to /workspace when no host WorkspacePath is set (the clone-inside-container case introduced by PR #654). * fix(docker-profile): persist Dockerfile/image_tag and apply tag override at runtime Two bugs: 1. The Dockerfile editor and Image Tag input on the executor profile page stored their values in component-local useState inside DockerSections, not in the form state. Saving the profile never wrote them back, so "Use defaults" / typing in the editor / changing the tag never persisted — and the Build button was the only thing that consumed them. The same gap existed in the new-executor flow's buildProfileConfig. Lift `dockerfile` and `image_tag` into useProfileFormState (edit) and useCreateProfileFormState (new), and emit them in buildSaveConfig / buildProfileConfig so they round-trip through profile.config. 2. Even when an image_tag is set on the profile, the container builder ignored it. imageName was always derived from the agent runtime's hardcoded `kandev/multi-agent`, so a user who built a custom image like `kandev/agent:latest` would still see Docker fail with "No such image: kandev/multi-agent:latest". Propagate `profile.config.image_tag` → metadata (`image_tag_override`) → ExecutorCreateRequest → ContainerConfig, and let it override `rt.Image` in buildContainerConfig when set. Also align the frontend default image tag (kandev/multi-agent:latest) with the backend's hardcoded runtime default so users who don't override end up with a working build. * test(docker): cover container config build + Docker profile persistence Adds two regression tests for the recent Docker fixes: - container_config_test.go (Go unit): asserts buildContainerConfig expands `{workspace}` in WorkingDir (defaulting to /workspace when no host WorkspacePath is set) and that ContainerConfig.ImageTagOverride takes precedence over the agent runtime's hardcoded `rt.Image`. No Docker daemon required. - docker-profile-persistence.spec.ts (Playwright E2E): creates a local_docker executor + profile, opens the profile edit page, clicks "Use defaults" to populate the Image Tag input + Dockerfile editor, hits Save, then refetches the profile via API and reloads the page to verify both fields round-trip through `profile.config`. Catches the prior regression where DockerSections owned the form state locally and never wrote it back. Runs against the existing E2E backend that has KANDEV_DOCKER_ENABLED=false — we only test the persistence path, not container creation. Adds an api-client helper (`getExecutorProfile`) for the round-trip assertion. * feat(docker): self-contained Dockerfile + mount agentctl + capabilities adapter Make the Docker executor work with a Dockerfile users can actually build, without depending on a maintainer-shipped image: - The container now launches a small bootstrap (`sh -c "$KANDEV_PREPARE_SCRIPT ; exec /usr/local/bin/agentctl"`) regardless of the image's ENTRYPOINT. The agent CLI is no longer wired through container Cmd; agentctl receives it later via its CreateInstance HTTP API. - Host agentctl-linux binary is mounted into the container at /usr/local/bin/agentctl via the existing AgentctlResolver. User-built images don't have to bake it in. - Default Dockerfile in the executor profile UI replaced with a working node:22-slim + git + curl image — runs npx-based ACP agents (Claude, Codex, OpenCode, Auggie) out of the box. - lifecycleAdapter now exposes RequiresCloneURL / ShouldApplyPreferredShell so the orchestrator can correctly identify containerized executors and resolve the clone URL. Without this, applyRepositoryConfig short-circuits on `e.capabilities == nil`, the cloneURL is never resolved, and the prepare script fails with `repository '{{repository.clone_url}}' does not exist`. - applyRepositoryConfig also surfaces the resolved clone URL to script metadata so the prepare script can interpolate {{repository.clone_url}}. - docker.ContainerConfig gains an Entrypoint field, plumbed to the docker client. Verified end-to-end on a local stack with the new minimal image: the container starts, bootstrap clones the GitHub repo, agentctl launches and hands off to claude-acp inside the container. * fix(docker): patient health check + resilient bootstrap Two follow-ups to the Docker bootstrap work: - The 30 retries × 500ms = 15s budget for agentctl health was too tight. In the previous flow agentctl was a separate exec from the prepare script, so it came up immediately. Now it runs after the prepare script (which does git clone, optional installs), so first launch can easily exceed 15s and produce a misleading "agentctl not healthy after 30 retries" error. Bumped to 240 retries (120s) and surfaced the last underlying error in the message so failures point to the real cause. - Prepare scripts commonly start with `set -e`/`set -eu`; running them via `eval` propagated the option to the outer bootstrap shell, so any failure killed the bootstrap before `exec /usr/local/bin/agentctl`. Run prepare in a subshell so its `set -e` is contained, and start agentctl regardless. If prepare fails, agentctl still comes up — the host can connect, surface the failure, and let the user debug from the Executor Settings popover. * fix(executor-profile): persist remote auth secrets/credentials for Docker profiles The Remote Credentials card (per-agent secret picker, plus the GitHub credential checkbox) is rendered for any remote executor — Sprites, local_docker, or remote_docker — but the save path in both the create and edit pages only emitted `remote_auth_secrets` and `remote_credentials` when `isSprites` was true. Selecting a secret for the Claude / Codex / Auggie ANTHROPIC_API_KEY-style entry on a Docker profile updated form state, the dropdown showed it as picked, but Save Changes silently dropped the value before posting to the API. On reload the agent showed as "Not configured", and any session start failed with `{"code":-32000,"message":"Authentication required"}`. Switch the guards to `isRemote` so the same data persists for all remote profile types. The backend orchestrator already propagates `profile.config.remote_auth_secrets` and `remote_credentials` through to metadata for any containerized executor and resolves them via `applyContainerCredentials` → `resolveAuthSecrets`, so no backend change is needed once the values actually land in the saved profile. * fix(executor-profile): live Configured badge + session/load resilience + live container status Three independent fixes that all surface together while iterating on the Docker executor flow: - Remote Credentials card: the per-agent "Configured" / "Not Configured" badge was driven by useState that only seeded once on mount. Picking a secret in the dropdown updated the parent's `agentEnvVars` map but the local `choice` never moved to `"env"`, so the badge stayed grey until the page was reloaded. Derive `choice` straight from props every render so the badge tracks live state. - ACP session/load: when the agent process restarted between turns (common with claude-agent-acp because its session map is in-memory), the host's session/load call failed with JSON-RPC -32002 "Resource not found" and the entire launch errored out. Treat that as the same kind of "session is gone" signal as -32601 / capability=false and fall back to session/new. Worst case the agent loses prior context; best case the user just keeps typing. - Executor Settings popover: dropped Execution ID and Environment ID rows (debug-only noise users don't need), kept Container ID + the copyable `docker exec -it … bash` line, added a live status badge with tones for running / exited / paused / dead / error / missing, and a 3-second polling interval so the badge follows the real container state. New `GET /api/v1/tasks/:id/environment/live` endpoint plumbs Docker `inspect` data through the lifecycle manager → task service → handler. ContainerLiveStatus mirror type at the service layer keeps lifecycle types out of the public surface. * feat(web): gate agent profiles by executor profile credentials Filter the agent profile dropdown in both the Create Task dialog and the New Session dialog to only show agents that are actually configured for the selected executor profile. For local/worktree executors, no gating (agents need no extra creds). For Docker/Sprites profiles, an agent is "configured" iff one of its remote-auth methods has a value in `remote_credentials` or a non-null secret in `remote_auth_secrets`. Agents without an entry in the remote auth catalog (Copilot, Amp, OpenCode, TUI, mock) cannot carry creds on remote executors and are blocked. Spec IDs match `AgentProfileOption.agent_name` (the registry-type string like "claude-acp"); `agent_id` is a DB UUID and unrelated to the catalog. The Create Task footer surfaces a "No agent profile is configured for X" tooltip and disables submit when the selected executor profile has no compatible agents. The New Session dialog auto-swaps to the first compatible agent and shows the same banner inline. `useRemoteAuthSpecs` returns a `loaded` flag so callers don't filter during the initial fetch (would otherwise hide everything). * feat(docker): make mock-agent runnable inside Docker for E2E Lay the backend groundwork so the existing mock-agent can run inside local_docker containers — needed for real-Docker E2E tests that don't require Anthropic/OpenAI API keys. Container builder bind-mounts a host mock-agent binary at /usr/local/bin/mock-agent when KANDEV_MOCK_AGENT_LINUX_BINARY is set (or build/mock-agent-linux-amd64 sits next to the kandev binary), the same pattern already used for agentctl. MockAgent.BuildCommand always resolves via PATH so the same command works on host (E2E PATH includes bin/) and in containers (mounted at /usr/local/bin/mock-agent). Mock now declares non-nil empty RemoteAuth so it counts as a remote-capable agent. The remoteauth catalog includes specs even with zero methods, and the frontend gating helper treats an empty-method spec as "no credentials needed" — letting mock-agent pass the configure-on-executor check that hides uncatalogued agents on remote executors. repositoryCloneURL falls back to the local origin remote URL when the repo has no provider info, so local-only repos with a real remote (including file:// remotes used in E2E) work on remote executors that clone inside the container. Makefile: build-mock-agent-linux target mirrors build-agentctl-linux. * test(docker-e2e): add real-Docker Playwright project + first spec A new Playwright project — \`docker\` — runs container-bound E2E specs against a backend with KANDEV_E2E_DOCKER=1. The fixture probes for a Docker daemon and skips the worker entirely when none is reachable, so contributors without Docker can still run the rest of the suite. The fixture builds a tiny kandev-agent:e2e image once per machine (node:22-slim + git/curl), pre-seeds a local_docker executor profile, and seeds a local repo with a file:// origin so the executor's clone-inside-container path resolves without network access. A small WS-based session.launch helper is added to the api-client for tests that need to launch additional sessions on an existing task. First spec — docker-launch.spec.ts — covers the smoke path: a session launches in a real container, the task environment records its container_id, and \`docker inspect\` confirms the container exists. Two follow-up specs (multi-session env reuse + recovery from external container removal) are wired up but marked test.fixme with comments pinpointing the backend gaps that block them: persistTaskEnvironment isn't called for workflow-prepared sessions (so task_environment_id stays empty on those rows), and DockerExecutor.reconnectToContainer loses the agentctl handshake auth token across launches (so reconnect 401s and falls back to a fresh container every time). Run locally with: \`pnpm --filter @kandev/web exec playwright test --config e2e/playwright.config.ts --project=docker\`. * ci(e2e): add dedicated Docker E2E job The new \`docker\` Playwright project needs a Docker daemon and the linux/amd64 agentctl + mock-agent binaries bind-mounted into containers. Pin the existing sharded job to chromium + mobile-chrome so the docker project no longer leaks into every shard (where it would fail seeding without KANDEV_DOCKER_ENABLED), and add an \`e2e-docker\` job that: - Verifies the Docker daemon is reachable on the runner. - Downloads the same backend artifact (now including the linux helper binaries, built via \`make build-backend-linux-helpers\`). - Runs \`playwright test --project=docker\` with KANDEV_E2E_DOCKER=1. - Cleans up any kandev-managed containers at teardown so leaks don't poison the next run. * ci(e2e): include docker job in merged HTML report Upload the docker job's blob report under the \`blob-report-*\` glob the merge job already consumes, and add e2e-docker to e2e-report's needs so the final HTML report covers both the sharded chromium runs and the docker run. * ci(e2e): add workflow_dispatch for ad-hoc runs and draft-PR verification * fix(build): always produce linux/amd64 agentctl as part of \`make build\` Remote executors (local Docker, remote Docker, Sprites) bind-mount the linux/amd64 agentctl binary into the container. On non-Linux hosts (e.g. macOS dev installs) running plain \`make build\` produced only the host binary, so the first Docker session would fail with: failed to build container config: agentctl linux binary not found The top-level \`make dev\` already special-cased non-Linux hosts to chain \`build-agentctl-linux\`, but \`make build\` and \`make start\` skipped it. Add it as a dependency of the standard backend build so every install ships a usable linux variant. Cross-compiling agentctl is fast (~1-2s) and the binary is ~20 MB, so the cost is negligible. Users who hit the error can run \`make build-backend\` (or \`make build\` in apps/backend) to regenerate; setting KANDEV_AGENTCTL_LINUX_BINARY remains a manual override. * fix(release): ship linux/amd64 agentctl in every release artifact Remote executors (local Docker, remote Docker, Sprites) bind-mount the linux/amd64 agentctl into the container. The release pipeline only shipped a host-arch agentctl, so users who installed via the bundled binaries (npm/Homebrew/etc.) hit the same "agentctl linux binary not found" error the first time they tried a Docker executor — even on Linux, since the resolver expects the -linux-amd64 suffix. Changes: - .github/workflows/release.yml: cross-compile agentctl-linux-amd64 in the build-bundles matrix step (CGO off so it works from every host). Bundle ships kandev + host agentctl + agentctl-linux-amd64 in dist/kandev/bin/. The AgentctlResolver finds it next to the kandev binary automatically. - Dockerfile: same idea for the container image — produce both variants in the Go builder stage and copy the linux-amd64 helper into /app/apps/backend/bin/ so the resolver picks it up inside the image. A previous commit added the same dependency to \`make build\` for local dev installs; this commit closes the gap for end users. * fix: address PR review feedback (CI, CodeRabbit, Greptile, Claude) Backend: - service_task_environments: fail closed when checkAnySessionRunning errors so a transient DB hiccup can't bypass the running-session guard and tear down a live container/sandbox; teardown is now truly best-effort with errors.Join across container/sandbox/worktree, so a failed Docker call doesn't permanently orphan the worktree on retry. Adds tests for the running-check error path and the multi-resource best-effort path. - executor_execute: propagate GetTaskEnvironmentByTaskID errors instead of silently launching a fresh environment that orphans existing containers/worktrees; gate environment reuse on matching ExecutorType so a profile switch doesn't pass stale PreviousExecutionID/container IDs to the wrong backend. - task_environment_handlers: reject malformed JSON on POST /tasks/:id/environment/reset (still allow empty body) so a destructive endpoint doesn't silently proceed with a bad payload. - worktree (PushEnvironmentBranch): bound git push with a 30s context timeout and disable interactive prompts via GIT_TERMINAL_PROMPT=0 so a stalled remote can't hang the request thread. - manager_environment: explicitly call ensureClient() before ContainerMgr() in DestroyContainer/GetContainerLiveStatus so a missing Docker daemon surfaces as "docker unavailable" rather than the generic "container manager not initialized". - container: WorkingDir always uses the in-container /workspace path rather than the host workspace path (Docker would happily start in an unrelated directory in host bind-mount mode); bootstrap now logs the prepare-script exit code so failures show up in container logs. - docker/client: CreateContainerInteractive honors ContainerConfig.Entrypoint so the same config struct produces consistent behavior on both creation paths. Frontend: - executor-settings-button: extract useTaskEnvironment hook (component shrinks below the 100-line limit); only treat 404 as empty env (other errors no longer wipe a valid environment); track "loaded once" via a ref so loadEnv doesn't depend on env state — that dep was creating a new function identity on every successful poll, cancelling the 3-second interval and turning the popover into a tight loop hammering /environment/live; copy-shell command uses sh instead of bash so it works on user-built minimal images. - task-reset-env-confirm-dialog: AlertDialogAction onClick calls e.preventDefault() so Radix's auto-close doesn't dismiss the dialog before the async reset completes (loading spinner now visible; close is parent-controlled via onOpenChange). - remote-credentials-card: track env method in selectedIds the same way files/gh_cli_token are tracked, and let initialChoice match either selectedIds OR envSecretId. First-time env-credential setup for an agent that exposes both files+env was broken: clicking the env radio never updated state, choice re-derived to "none", and the secret picker disappeared before the user could pick anything. - e2e/executor-reuse: select the acknowledge checkbox by label, not by positional last(), so adding another checkbox to the dialog can't silently target the wrong control. Format pass on prettier-flagged files; gofmt fixed two whitespace nits in lifecycle files. * test(executor): cover applyExistingEnvironment executor-type mismatch guard * fix(docker): connect agentctl through published ports * fix(web): keep remote auth selection stable * fix(docker): reuse task containers across sessions * fix(docker): resume task environment after refresh * fix(docker): track executor containers for cleanup * fix(docker): explain missing agent credentials * fix(docker): surface container launch progress * fix(docker): stabilize container reuse and feedback * fix(docker): show task titles in container lists * fix(docker): use container icon for docker tasks * fix(orchestrator): align environment reuse after rebase * fix(sprites): reconnect sessions on existing sandboxes * fix(docker): use container icon for local docker executor too Local docker tasks were rendering the Docker brand "chip" mark while remote docker uses the container glyph. Map both variants to IconContainer so the executor surfaces look consistent. * feat(sprites): provision a fresh sandbox when the persisted one is gone Resuming a Sprites task whose sandbox was destroyed upstream returned "sprite not found" and dead-ended the user. Detect the upstream 404 via a typed ErrSpriteNotFound sentinel, mark the reconnect step skipped with a user-facing warning explaining what happened, swap the prepare plan in place to the fresh-provision shape, and continue the launch under a new sandbox name on the same branch. The progress plan is now mutable so the reporter can stream the fresh steps live after the reconnect-then-fresh transition. Only the explicit not-found case triggers fallback; other reconnect failures still surface as before, and the fallback fires at most once per launch. * feat(web): friendlier header for sandbox-recovered prepare runs When the only warning on a completed prepare is the missing-sandbox fallback notice, render "Environment prepared on a fresh sandbox" in the header instead of the generic "Environment prepared with warnings". The fallback row still carries its own amber warning callout so the user understands why a fresh sandbox was provisioned. * feat(web): unify executor surfaces in the right-side popover The desktop task top bar carried a small cloud icon (RemoteCloudTooltip) in the breadcrumb cluster while the right-side Executor popover showed the same kind of info — two surfaces for one concept. Remove the cloud icon from the desktop top bar; kanban cards, task list rows, and the mobile top bar keep theirs since they have no popover. The popover button now picks its icon by executor type (cloud for sprites, container for docker, server for local PC) using a shared getExecutorStatusIcon helper. While the prepare progress is preparing the icon swaps to a spinner so the user can see at a glance that work is in flight. The popover content gains a Preparing-environment section that streams "Step N of M: ", an amber inline note when the missing-sandbox fallback is in progress, an Environment-ready row with duration on success, and a failed-step summary on failure. Polling promotes to the active 3s cadence while preparing so the icon flips to ready without the user opening the popover. * fix(docker): swap container glyph for a plain box icon IconContainer's vertical ribbing reads as a microchip on small sizes. IconBox is an unambiguous outlined cube. Use it for both docker variants so the executor surfaces look like a box, not a chip. * fix(orchestrator): preserve base branch on resume for clone-based executors Sprites and Docker resumes were sending the launch request with an empty BaseBranch, so the in-sandbox prepare script's `git clone --branch ` shifted positional args and failed with "repository '/workspace' does not exist". applyResumeRepoConfig only set BaseBranch inside the worktree-specific block; widen that to all executors that require a clone URL. Local executors are intentionally left untouched: LocalPreparer reads BaseBranch and forces a `git fetch && git checkout` against the user's own workspace, which would clobber the "use current state" path. * feat(sprites,docker): generate a kandev feature branch for clone-based executors Sprites tasks committed straight onto the chosen base branch because SpritesPreparer didn't populate WorktreeBranch and the orchestrator's "forward env.WorktreeBranch on reuse" check only fired for Docker. Mirror the Docker preparer's TaskBranchName generation in Sprites, extract a shared `nonWorktreeTaskBranch` helper that uses a deterministic suffix derived from the task ID (so legacy env rows that predate this change resolve to the same branch on every resume), and let the orchestrator forward env.WorktreeBranch for any clone-based executor — Docker or Sprites. * fix(sprites,docker): prefer existing remote feature branch on resume When a sandbox is destroyed and we provision a fresh one for the same task, the agent may have previously committed and pushed onto the feature branch. The previous prepare scripts only ran `git checkout -b {{worktree.branch}}` which would overwrite that progress with a fresh branch off the chosen base. Try `git fetch origin ` first; on success, `git checkout -B` against the remote tip so the agent picks up where it left off. Falls back to a fresh local branch when origin has nothing. Also add the same checkout block to the Sprites prepare script — it was missing entirely, so every Sprites session was committing onto main. * test(sprites): lock in informative branch name in fallback warning Now that BaseBranch is preserved on resume for clone-based executors, the missing-sandbox fallback warning detail names the actual branch ("Cloning … on the same branch main") instead of the "(unknown)" placeholder. Add a regression test so a future change to the resume metadata propagation can't silently regress that copy. * fix(sprites): preserve sandbox on plain agent stop so resume is fast StopInstance was calling sprite.Destroy() unconditionally, so every session stop tore down the cloud sandbox. Resume then always fell into the missing-sandbox fallback, re-cloned the repo, re-ran the prepare script, and lost the agent's working tree. That's a 30s+ round-trip for what should be "stop the process, then re-attach later". Limit Destroy to terminal lifecycle reasons (task/session deleted/archived), the same set runTerminalCleanupScript already uses. Plain stops just close the proxy and leave the sandbox alive. Also update the existing Docker e2e regex: the feature-branch suffix is now a deterministic 6-hex-char prefix of the task UUID rather than a random 3-char tail, so resume on legacy env rows lands on the same branch. * test(docker): cover stop + resume → same container, branch preserved Adds a Docker e2e test that exercises the user-visible stop-then-resume flow: launch session, stop it via WS session.stop, launch a fresh session on the same task, and assert the same task environment + container ID are reused and the kandev feature branch is preserved. Also nudge the executor popover padding: when the prepare-status section is rendered above the Sprites/Docker info block, the cumulative top+bottom paddings looked too generous around "Sprites sandbox" / "Local Docker". Tighten both sections vertically without affecting the horizontal rhythm or the bottom action row. * feat(web): show "Resuming session" feedback in popover during reconnect The bottom Resume button on the chat panel was leaving users staring at nothing for the 5-30s reconnect window: prepare-progress events are intentionally suppressed on resume (the workspace is already there), so the popover sat in the idle phase even while session.state was STARTING. Synthesize a "resuming" phase from session state when no prepare events are flowing, rendered as a spinner row in the popover with copy "Resuming session — Reconnecting to the existing environment…". The button icon also flips to the spinner so the toolbar advertises the in-flight work without the user having to open the popover. Extract the prepare-status reducer + section component into their own file so the main popover module stays under the 600-line ESLint cap. * fix(codex): align with the standard SessionDirTemplate pattern Codex was the only agent that bind-mounted its host home into the container via an explicit `Mounts: [{home}/.codex → /root/.codex]` entry, while every other ACP agent uses `SessionConfig.SessionDirTemplate` + `SessionDirTarget`. Move codex onto the same surface so the host-state isolation work in the next commit covers it uniformly. This change alone does not stop the host-state leak — the bind mount still resolves to the host's ~/.codex for now — but it removes the last agent-specific branch in the runtime config so the docker container manager can apply one rule for all agents. * fix(docker): isolate per-container agent state from the host home Every ACP agent's session dir was bind-mounted from ~/. on the host into /root/. in the container. That exposed host state DBs, session caches, and lock files to the container — codex's rollout DB caches absolute /Users/... paths, which don't resolve inside Linux and the agent silently loses prior thread context on resume. Replace the host bind mount with a kandev-managed per-container session dir under /agent-sessions//. Before the container starts, selectively copy the auth-relevant files (the same files Sprites already copies via RemoteAuth.SourceFiles) into that dir; everything else — state DBs, sessions/, lock files — stays on the host and the agent rebuilds them inside the container with container-local Linux paths. The change is agent-agnostic: any agent that has SessionDirTemplate + SessionDirTarget gets the new behavior automatically. Cleanup runs on destructive stops only (task/session deleted/archived), mirroring the Sprites preserve-on-stop rule so plain stops let resume re-attach to the same agent state. * fix(executor): make the kandev feature-branch checkout an invariant postlude Profiles created in the UI snapshot the *then-current* DefaultPrepareScript into their stored prepare_script field. Updates to the default — like shipping the worktree-branch checkout block — never reach those older profiles, so users keep landing the agent on the chosen base branch (main) even though the env row carries a feature branch name. Append a kandev-managed postlude to whatever prepare_script ran (custom or default), wrapped in a subshell + `|| true` so it's idempotent and never blocks agentctl from starting. The postlude prefers the remote tip when origin already has the branch (resume after a destroyed sandbox), falls back to checking out the existing local branch, and finally creates one off the current HEAD. Sprites needed the WorktreeProvider in resolvePrepareScript so the postlude's `{{worktree.branch}}` actually expands; Docker already had it. The Docker e2e gains a regression case that explicitly stores an older default-style script (clone, no checkout) and asserts the agent still ends up on the feature branch — locking the contract in regardless of which default the user happens to have stored. * fix(web): tighten executor popover padding under the env header The Sprites/Docker info block under the Environment-ready row had too much vertical breathing room — the header-to-fields gap and the inter-row spacing piled up and the popover felt airy. Trim the section to match the snug rhythm of the rest of the popover (px-3 py-2, 1.5-unit gap before the field list, 1-unit gap between rows). Also unify the Environment-ready row from p-3 to px-3 py-2 so the dividers between sections sit at the same vertical cadence. * fix(web): tighten executor popover bottom padding too The previous tighten only fixed the top half — pb under the field rows plus the Reset-row's full p-2 still left a generous gap below the last "Sprite ..." line. Same fix applies whether the popover is showing Docker or Sprites. EnvironmentInfo: pt-2 pb-1.5 (was py-2) so the bottom field hugs the divider, and the Reset-row goes from p-2 to px-2 py-1.5 so the button sits closer to the text above. * fix(web): drop the redundant Environment-ready row + tighten popover top The READY badge next to the executor name already conveys ready-state; the dedicated "Environment ready · 12s" row above the executor info just added vertical noise. Skip rendering for the ready phase (other phases that surface non-redundant info — preparing, resuming, failed — still render). Drop top padding on the executor info section now that nothing sits above it on the happy path. * fix(web): move executor info to the left top bar, hover-to-open, no border The executor section is informational, not a button — readiness state + sandbox/container/branch fields. Move it to the left top bar (next to the integrations menu) where it sits alongside the other contextual info, drop the outline-button styling, and switch from click-to-open to hover-to-open via HoverCard with 150ms open / 250ms close so the cursor can travel to the destructive Reset button without flickering. The trigger is a borderless icon-with-status-dot span; tabIndex keeps it keyboard-accessible. Reset still works — it opens the existing confirmation dialog independently of the hover card. Tests: pointerEnter instead of click for the hover-to-open path. * refactor(cleanup): drop dead code, dedupe predicates, unify token injection Tier 1 of the post-review cleanup. Each item is independently safe; bundling because they're all small, mechanical, and lint-passing. - fix(clone): unify injectTokenIntoURL across Docker and Sprites — Sprites used to ignore GH_TOKEN entirely (a user with only GH_TOKEN set got 401 on Sprites clone but worked on Docker). Promote one `injectGitHubTokenIntoCloneURL` helper, accept both env vars (GITHUB_TOKEN wins, matching Docker), and use the documented `https://x-access-token:T@github.com/` form on both surfaces. - fix(executor-popover): swap the trigger for - @@ -303,9 +321,10 @@ function DeleteProfileDialog({ function useProfilePersistence(executor: Executor, profile: ExecutorProfile) { const router = useRouter(); + const { toast } = useToast(); const executors = useAppStore((state) => state.executors.items); const setExecutors = useAppStore((state) => state.setExecutors); - const [saving, setSaving] = useState(false); + const [saveStatus, setSaveStatus] = useState("idle"); const [error, setError] = useState(null); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleting, setDeleting] = useState(false); @@ -317,24 +336,22 @@ function useProfilePersistence(executor: Executor, profile: ExecutorProfile) { cleanup_script: string; env_vars: ProfileEnvVar[]; }) => { - setSaving(true); + setSaveStatus("loading"); setError(null); try { const updated = await updateExecutorProfile(executor.id, profile.id, data); - setExecutors( - executors.map((e: Executor) => - e.id === executor.id - ? { ...e, profiles: e.profiles?.map((p) => (p.id === updated.id ? updated : p)) } - : e, - ), - ); + setSaveStatus("success"); + toast({ title: "Profile saved", variant: "success" }); + setExecutors(upsertExecutorProfile(executors, executor, updated)); + window.setTimeout(() => setSaveStatus("idle"), 1500); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to save profile"); - } finally { - setSaving(false); + const message = err instanceof Error ? err.message : "Failed to save profile"; + setError(message); + setSaveStatus("error"); + toast({ title: "Failed to save profile", description: message, variant: "error" }); } }, - [executor.id, profile.id, executors, setExecutors], + [executor, profile.id, executors, setExecutors, toast], ); const remove = useCallback(async () => { @@ -355,7 +372,7 @@ function useProfilePersistence(executor: Executor, profile: ExecutorProfile) { } }, [executor.id, profile.id, executors, setExecutors, router]); - return { saving, error, deleting, deleteDialogOpen, setDeleteDialogOpen, save, remove }; + return { saveStatus, error, deleting, deleteDialogOpen, setDeleteDialogOpen, save, remove }; } function useProfileFormState(executor: Executor, profile: ExecutorProfile) { @@ -485,7 +502,7 @@ function ProfileEditForm({ executor, profile }: { executor: Executor; profile: E {persistence.error &&

{persistence.error}

} persistence.setDeleteDialogOpen(true)} diff --git a/apps/web/app/settings/executors/[profileId]/page.tsx b/apps/web/app/settings/executors/[profileId]/page.tsx index ad4fa57969..70cc420b9d 100644 --- a/apps/web/app/settings/executors/[profileId]/page.tsx +++ b/apps/web/app/settings/executors/[profileId]/page.tsx @@ -9,6 +9,7 @@ import { useSecrets } from "@/hooks/domains/settings/use-secrets"; import { updateExecutorProfile, deleteExecutorProfile, + removeDockerContainer, fetchLocalGitIdentity, listScriptPlaceholders, } from "@/lib/api/domains/settings-api"; @@ -33,11 +34,15 @@ import { DockerSections, SpritesSections, } from "@/components/settings/profile-edit/profile-runtime-sections"; +import { useDockerProfileContainers } from "@/components/settings/profile-edit/docker-sections"; import { ProfileHeader, ProfileFormActions, DeleteProfileDialog, + upsertExecutorProfile, + type SaveStatus, } from "@/components/settings/profile-edit/profile-edit-page-chrome"; +import { useToast } from "@/components/toast-provider"; import type { Executor, ExecutorProfile, ProfileEnvVar } from "@/lib/types/http"; import type { NetworkPolicyRule } from "@/lib/api/domains/settings-api"; @@ -196,9 +201,10 @@ export default function ProfileEditPage({ params }: { params: Promise<{ profileI function useProfilePersistence(executor: Executor, profile: ExecutorProfile) { const router = useRouter(); + const { toast } = useToast(); const executors = useAppStore((state) => state.executors.items); const setExecutors = useAppStore((state) => state.setExecutors); - const [saving, setSaving] = useState(false); + const [saveStatus, setSaveStatus] = useState("idle"); const [error, setError] = useState(null); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleting, setDeleting] = useState(false); @@ -212,45 +218,47 @@ function useProfilePersistence(executor: Executor, profile: ExecutorProfile) { cleanup_script: string; env_vars: ProfileEnvVar[]; }) => { - setSaving(true); + setSaveStatus("loading"); setError(null); try { const updated = await updateExecutorProfile(executor.id, profile.id, data); + setSaveStatus("success"); + toast({ title: "Profile saved", variant: "success" }); + setExecutors(upsertExecutorProfile(executors, executor, updated)); + window.setTimeout(() => setSaveStatus("idle"), 1500); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to save profile"; + setError(message); + setSaveStatus("error"); + toast({ title: "Failed to save profile", description: message, variant: "error" }); + } + }, + [executor, profile.id, executors, setExecutors, toast], + ); + + const remove = useCallback( + async (beforeDelete?: () => Promise) => { + setDeleting(true); + try { + await beforeDelete?.(); + await deleteExecutorProfile(executor.id, profile.id); setExecutors( executors.map((e: Executor) => e.id === executor.id - ? { ...e, profiles: e.profiles?.map((p) => (p.id === updated.id ? updated : p)) } + ? { ...e, profiles: e.profiles?.filter((p) => p.id !== profile.id) } : e, ), ); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to save profile"); - } finally { - setSaving(false); + router.push(EXECUTORS_ROUTE); + } catch { + setDeleting(false); + setDeleteDialogOpen(false); } }, - [executor.id, profile.id, executors, setExecutors], + [executor.id, profile.id, executors, setExecutors, router], ); - const remove = useCallback(async () => { - setDeleting(true); - try { - await deleteExecutorProfile(executor.id, profile.id); - setExecutors( - executors.map((e: Executor) => - e.id === executor.id - ? { ...e, profiles: e.profiles?.filter((p) => p.id !== profile.id) } - : e, - ), - ); - router.push(EXECUTORS_ROUTE); - } catch { - setDeleting(false); - setDeleteDialogOpen(false); - } - }, [executor.id, profile.id, executors, setExecutors, router]); - - return { saving, error, deleting, deleteDialogOpen, setDeleteDialogOpen, save, remove }; + return { saveStatus, error, deleting, deleteDialogOpen, setDeleteDialogOpen, save, remove }; } function useProfileFormState(executor: Executor, profile: ExecutorProfile) { @@ -258,6 +266,8 @@ function useProfileFormState(executor: Executor, profile: ExecutorProfile) { const [mcpPolicy, setMcpPolicy] = useState(profile.mcp_policy ?? ""); const [prepareScript, setPrepareScript] = useState(profile.prepare_script ?? ""); const [cleanupScript, setCleanupScript] = useState(profile.cleanup_script ?? ""); + const [dockerfile, setDockerfile] = useState(profile.config?.dockerfile ?? ""); + const [imageTag, setImageTag] = useState(profile.config?.image_tag ?? ""); const { envVarRows, addEnvVar, removeEnvVar, updateEnvVar } = useEnvVarRows(profile.env_vars); const [placeholders, setPlaceholders] = useState([]); const [spritesSecretId, setSpritesSecretId] = useState(() => @@ -295,6 +305,10 @@ function useProfileFormState(executor: Executor, profile: ExecutorProfile) { setPrepareScript, cleanupScript, setCleanupScript, + dockerfile, + setDockerfile, + imageTag, + setImageTag, envVarRows, addEnvVar, removeEnvVar, @@ -334,7 +348,7 @@ function buildSaveConfig( } else { delete config.sprites_network_policy_rules; } - if (form.isSprites && form.remoteCredentials.length > 0) { + if (form.isRemote && form.remoteCredentials.length > 0) { config.remote_credentials = JSON.stringify(form.remoteCredentials); } else { delete config.remote_credentials; @@ -342,7 +356,7 @@ function buildSaveConfig( const nonNullEnvVars = Object.fromEntries( Object.entries(form.agentEnvVars).filter(([, v]) => v != null), ); - if (form.isSprites && Object.keys(nonNullEnvVars).length > 0) { + if (form.isRemote && Object.keys(nonNullEnvVars).length > 0) { config.remote_auth_secrets = JSON.stringify(nonNullEnvVars); } else { delete config.remote_auth_secrets; @@ -365,9 +379,27 @@ function buildSaveConfig( } else { delete config.git_user_email; } + applyDockerConfig(config, form); return config; } +function applyDockerConfig( + config: Record, + form: ReturnType, +): void { + if (!form.isDocker) return; + if (form.dockerfile.trim()) { + config.dockerfile = form.dockerfile; + } else { + delete config.dockerfile; + } + if (form.imageTag.trim()) { + config.image_tag = form.imageTag.trim(); + } else { + delete config.image_tag; + } +} + function ProfileEditSections({ executor, profile, @@ -389,7 +421,15 @@ function ProfileEditSections({ secrets={secrets} /> )} - {form.isDocker && } + {form.isDocker && ( + + )} {form.isRemote && ( { @@ -465,6 +506,18 @@ function ProfileEditForm({ executor, profile }: { executor: Executor; profile: E }); }; + const handleDelete = (options?: { removeRelatedDockerContainers?: boolean }) => { + const beforeDelete = options?.removeRelatedDockerContainers + ? async () => { + await Promise.all( + relatedContainers.containers.map((container) => removeDockerContainer(container.id)), + ); + await relatedContainers.refresh(); + } + : undefined; + void persistence.remove(beforeDelete); + }; + return (
{persistence.error}

} persistence.setDeleteDialogOpen(true)} @@ -491,8 +544,9 @@ function ProfileEditForm({ executor, profile }: { executor: Executor; profile: E
); diff --git a/apps/web/app/settings/executors/new/[type]/page.tsx b/apps/web/app/settings/executors/new/[type]/page.tsx index 3b8b0a3073..574015d2db 100644 --- a/apps/web/app/settings/executors/new/[type]/page.tsx +++ b/apps/web/app/settings/executors/new/[type]/page.tsx @@ -6,6 +6,7 @@ import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { Card, CardContent } from "@kandev/ui/card"; import { Separator } from "@kandev/ui/separator"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useAppStore } from "@/components/state-provider"; import { useSecrets } from "@/hooks/domains/settings/use-secrets"; import { @@ -27,7 +28,10 @@ import { rowsToEnvVars, } from "@/components/settings/profile-edit/env-vars-card"; import { ScriptCard } from "@/components/settings/profile-edit/script-card"; -import { DockerfileBuildCard } from "@/components/settings/profile-edit/docker-sections"; +import { + DockerfileBuildCard, + type DockerBuildSuccess, +} from "@/components/settings/profile-edit/docker-sections"; import { SpritesApiKeyCard } from "@/components/settings/profile-edit/sprites-api-key-card"; import { NetworkPoliciesCard } from "@/components/settings/profile-edit/sprites-sections"; import { @@ -144,13 +148,20 @@ function CreateProfileHeader({ function CreateFormActions({ saving, saveDisabled, + disabledReason, onSave, }: { saving: boolean; saveDisabled: boolean; + disabledReason: string | null; onSave: () => void; }) { const router = useRouter(); + const createButton = ( + + ); return (
- + {disabledReason ? ( + + + + {createButton} + + + {disabledReason} + + ) : ( + createButton + )}
); } @@ -170,6 +190,7 @@ function CreateFormActions({ type BuildProfileConfigInput = { isRemote: boolean; isSprites: boolean; + isDocker: boolean; networkPolicyRules: NetworkPolicyRule[]; remoteCredentials: string[]; agentEnvVars: Record; @@ -177,12 +198,15 @@ type BuildProfileConfigInput = { localGitIdentity: GitIdentityState; gitUserName: string; gitUserEmail: string; + dockerfile: string; + imageTag: string; }; function buildProfileConfig(input: BuildProfileConfigInput): Record | undefined { const { isRemote, isSprites, + isDocker, networkPolicyRules, remoteCredentials, agentEnvVars, @@ -190,18 +214,20 @@ function buildProfileConfig(input: BuildProfileConfigInput): Record = {}; if (isSprites && networkPolicyRules.length > 0) { config.sprites_network_policy_rules = JSON.stringify(networkPolicyRules); } - if (isSprites && remoteCredentials.length > 0) { + if (isRemote && remoteCredentials.length > 0) { config.remote_credentials = JSON.stringify(remoteCredentials); } const nonNullEnvVars = Object.fromEntries( Object.entries(agentEnvVars).filter(([, v]) => v != null), ); - if (isSprites && Object.keys(nonNullEnvVars).length > 0) { + if (isRemote && Object.keys(nonNullEnvVars).length > 0) { config.remote_auth_secrets = JSON.stringify(nonNullEnvVars); } if (isRemote) { @@ -216,9 +242,25 @@ function buildProfileConfig(input: BuildProfileConfigInput): Record 0 ? config : undefined; } +function applyDockerCreateConfig( + config: Record, + isDocker: boolean, + dockerfile: string, + imageTag: string, +): void { + if (!isDocker) return; + if (dockerfile.trim()) { + config.dockerfile = dockerfile; + } + if (imageTag.trim()) { + config.image_tag = imageTag.trim(); + } +} + function useDefaultScripts(executorType: string, setPrepareScript: (v: string) => void) { useEffect(() => { fetchDefaultScripts(executorType) @@ -305,7 +347,7 @@ function useCreateGitIdentityState(isRemote: boolean) { } function useCreateProfileFormState(executorType: string) { - const [name, setName] = useState(""); + const [name, setName] = useState(() => (executorType === "local_docker" ? "Docker" : "")); const [mcpPolicy, setMcpPolicy] = useState(""); const [prepareScript, setPrepareScript] = useState(""); const [cleanupScript, setCleanupScript] = useState(""); @@ -315,6 +357,7 @@ function useCreateProfileFormState(executorType: string) { const remoteAuth = useCreateRemoteAuthState(executorType); const [dockerfile, setDockerfile] = useState(""); const [imageTag, setImageTag] = useState(""); + const [builtDockerImage, setBuiltDockerImage] = useState(null); const flags = useCreateRemoteFlags(executorType); const gitIdentity = useCreateGitIdentityState(flags.isRemote); const mcpPolicyError = useMemo(() => validateMcpPolicy(mcpPolicy), [mcpPolicy]); @@ -335,6 +378,17 @@ function useCreateProfileFormState(executorType: string) { return vars; }, [envVarRows, flags.isSprites, spritesSecretId]); + const recordDockerBuildSuccess = useCallback((result: DockerBuildSuccess) => { + setBuiltDockerImage(result); + }, []); + + const dockerImageBuilt = + !flags.isDocker || + (Boolean(dockerfile.trim()) && + Boolean(imageTag.trim()) && + builtDockerImage?.dockerfile === dockerfile && + builtDockerImage?.imageTag === imageTag.trim()); + const prepareDesc = flags.isRemote ? "Runs inside the execution environment before the agent starts. Type {{ to see available placeholders." : "Runs on the host machine before the agent starts."; @@ -368,6 +422,8 @@ function useCreateProfileFormState(executorType: string) { setDockerfile, imageTag, setImageTag, + recordDockerBuildSuccess, + dockerImageBuilt, gitUserName: gitIdentity.gitUserName, setGitUserName: gitIdentity.setGitUserName, gitUserEmail: gitIdentity.gitUserEmail, @@ -393,6 +449,10 @@ function useCreateProfileSave( const handleSave = useCallback(async () => { if (!form.name.trim() || form.mcpPolicyError) return; + if (form.isDocker && !form.dockerImageBuilt) { + setError("Build this Docker image before creating the profile."); + return; + } if (form.isSprites && !form.spritesSecretId) { setError("Sprites API key is required."); return; @@ -406,6 +466,7 @@ function useCreateProfileSave( config: buildProfileConfig({ isRemote: form.isRemote, isSprites: form.isSprites, + isDocker: form.isDocker, networkPolicyRules: form.networkPolicyRules, remoteCredentials: form.remoteCredentials, agentEnvVars: form.agentEnvVars, @@ -413,6 +474,8 @@ function useCreateProfileSave( localGitIdentity: form.localGitIdentity, gitUserName: form.gitUserName, gitUserEmail: form.gitUserEmail, + dockerfile: form.dockerfile, + imageTag: form.imageTag, }), prepare_script: form.prepareScript, cleanup_script: form.cleanupScript, @@ -458,6 +521,7 @@ function CreateProfileSections({ onDockerfileChange={form.setDockerfile} imageTag={form.imageTag} onImageTagChange={form.setImageTag} + onBuildSuccess={form.recordDockerBuildSuccess} /> )} {form.isRemote && ( @@ -519,6 +583,23 @@ function CreateProfileSections({ ); } +function getCreateDisabledReason( + form: ReturnType, + spritesTokenMissing: boolean, + saving: boolean, +) { + if (saving) return "Creating profile..."; + if (!form.name.trim()) return "Enter a profile name."; + if (form.mcpPolicyError) return form.mcpPolicyError; + if (spritesTokenMissing) return "Add a Sprites API key before creating the profile."; + if (form.isDocker) { + if (!form.imageTag.trim()) return "Enter an image tag before creating the profile."; + if (!form.dockerfile.trim()) return "Add Dockerfile content before creating the profile."; + if (!form.dockerImageBuilt) return "Build this Docker image before creating the profile."; + } + return null; +} + function CreateProfileForm({ executorType, typeInfo, @@ -530,6 +611,7 @@ function CreateProfileForm({ const form = useCreateProfileFormState(executorType); const { saving, error, handleSave } = useCreateProfileSave(form, typeInfo.executorId); const spritesTokenMissing = form.isSprites && !form.spritesSecretId; + const disabledReason = getCreateDisabledReason(form, spritesTokenMissing, saving); return (
@@ -545,9 +627,8 @@ function CreateProfileForm({ {error &&

{error}

}
diff --git a/apps/web/components/kanban-card-content.tsx b/apps/web/components/kanban-card-content.tsx index f58ae17c24..9fd3a94fbd 100644 --- a/apps/web/components/kanban-card-content.tsx +++ b/apps/web/components/kanban-card-content.tsx @@ -115,6 +115,7 @@ export function KanbanCardBody({ )} diff --git a/apps/web/components/session/prepare-progress.test.tsx b/apps/web/components/session/prepare-progress.test.tsx new file mode 100644 index 0000000000..e50ce74fc7 --- /dev/null +++ b/apps/web/components/session/prepare-progress.test.tsx @@ -0,0 +1,120 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PrepareStepInfo } from "@/lib/state/slices/session-runtime/types"; + +let mockSteps: PrepareStepInfo[] = []; +let mockPrepareStatus: "preparing" | "completed" | "failed" = "preparing"; +let mockSessionState: string = "STARTING"; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + prepareProgress: { + bySessionId: { + "session-1": { + sessionId: "session-1", + status: mockPrepareStatus, + steps: mockSteps, + }, + }, + }, + taskSessions: { + items: { + "session-1": { + id: "session-1", + state: mockSessionState, + }, + }, + }, + sessionAgentctl: { + itemsBySessionId: {}, + }, + }), +})); + +import { PrepareProgress } from "./prepare-progress"; + +describe("PrepareProgress", () => { + afterEach(() => cleanup()); + + it("hides skipped steps that have no useful details", () => { + mockPrepareStatus = "preparing"; + mockSessionState = "STARTING"; + mockSteps = [ + { + name: "Uploading credentials", + status: "skipped", + }, + { + name: "Waiting for agent controller", + status: "completed", + }, + ]; + + render(); + + expect(screen.queryByText("Uploading credentials")).toBeNull(); + expect(screen.getByText("Waiting for agent controller")).toBeTruthy(); + }); + + it("keeps the fallback notice row visible because it carries a warning", () => { + mockPrepareStatus = "preparing"; + mockSessionState = "STARTING"; + mockSteps = [ + { + name: "Reconnecting cloud sandbox", + status: "skipped", + warning: + "Previous sandbox is no longer available — provisioning a fresh one for this branch.", + warningDetail: "Old sandbox could not be reached.", + output: "Old sandbox: kandev-old\nNew sandbox: kandev-new\nBranch: feature/foo", + }, + ]; + + render(); + + expect(screen.getByText("Reconnecting cloud sandbox")).toBeTruthy(); + expect( + screen.getByText( + "Previous sandbox is no longer available — provisioning a fresh one for this branch.", + ), + ).toBeTruthy(); + }); + + it("relabels the header when the only warning is the fallback notice", () => { + mockPrepareStatus = "completed"; + mockSessionState = "RUNNING"; + mockSteps = [ + { + name: "Reconnecting cloud sandbox", + status: "skipped", + warning: + "Previous sandbox is no longer available — provisioning a fresh one for this branch.", + }, + { name: "Creating cloud sandbox", status: "completed" }, + { name: "Waiting for agent controller", status: "completed" }, + ]; + + render(); + + expect(screen.getByText("Environment prepared on a fresh sandbox")).toBeTruthy(); + expect(screen.queryByText("Environment prepared with warnings")).toBeNull(); + }); + + it("uses the generic warnings header when warnings are unrelated to fallback", () => { + mockPrepareStatus = "completed"; + mockSessionState = "RUNNING"; + mockSteps = [ + { + name: "Uploading credentials", + status: "completed", + warning: "Some credentials skipped", + }, + ]; + + render(); + + expect(screen.getByText("Environment prepared with warnings")).toBeTruthy(); + expect(screen.queryByText("Environment prepared on a fresh sandbox")).toBeNull(); + }); +}); diff --git a/apps/web/components/session/prepare-progress.tsx b/apps/web/components/session/prepare-progress.tsx index bca7dd4098..00c27e03a5 100644 --- a/apps/web/components/session/prepare-progress.tsx +++ b/apps/web/components/session/prepare-progress.tsx @@ -12,6 +12,7 @@ import { } from "@tabler/icons-react"; import { useAppStore } from "@/components/state-provider"; import { ExpandableRow } from "@/components/task/chat/messages/expandable-row"; +import { isFallbackNoticeStep } from "@/lib/prepare/summarize"; import { cn } from "@/lib/utils"; import type { PrepareStepInfo, @@ -283,7 +284,13 @@ function getHeaderLabel(status: EffectiveStatus, prepareState: SessionPrepareSta // `||` (not `??`) so an empty-string error also falls through to the name fallback. return failed?.error || `${failed?.name ?? "Environment prepared"} — step failed`; } - if (status === "completed_with_warnings") return "Environment prepared with warnings"; + if (status === "completed_with_warnings") { + const warningSteps = prepareState.steps.filter((s) => s.warning); + if (warningSteps.length === 1 && isFallbackNoticeStep(warningSteps[0])) { + return "Environment prepared on a fresh sandbox"; + } + return "Environment prepared with warnings"; + } return "Environment prepared"; } @@ -300,6 +307,15 @@ function Mono({ children }: { children: React.ReactNode }) { return {children}; } +function hasStepDetails(step: PrepareStepInfo): boolean { + return Boolean(step.command || step.output || step.error || step.warning || step.warningDetail); +} + +function isVisibleStep(step: PrepareStepInfo): boolean { + if (step.status === "skipped" && !hasStepDetails(step)) return false; + return step.name.trim() !== "" || hasStepDetails(step); +} + function SessionInfo({ sessionId }: { sessionId: string }) { const session = useAppStore((state) => state.taskSessions.items[sessionId]); if (!session) return null; @@ -350,13 +366,7 @@ export function PrepareProgress({ sessionId }: PrepareProgressProps) { if (!prepareState) return null; const hasSessionInfo = Boolean(session?.worktree_path || session?.worktree_branch); - const visibleSteps = prepareState.steps.filter( - (step) => - step.name.trim() !== "" || - Boolean(step.output) || - Boolean(step.error) || - Boolean(step.warning), - ); + const visibleSteps = prepareState.steps.filter(isVisibleStep); const headerLabel = getHeaderLabel(status, prepareState); const isErrorStatus = status === "failed" || status === "completed_with_error"; const headerClass = cn("text-xs", isErrorStatus ? "text-destructive" : "text-muted-foreground"); diff --git a/apps/web/components/settings/profile-edit/docker-sections.test.ts b/apps/web/components/settings/profile-edit/docker-sections.test.ts new file mode 100644 index 0000000000..e5d0094390 --- /dev/null +++ b/apps/web/components/settings/profile-edit/docker-sections.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { containerTaskLabel } from "./docker-sections"; +import type { DockerContainer } from "@/lib/api/domains/settings-api"; + +const taskID = "task-abc123"; + +function container(labels?: Record): DockerContainer { + return { + id: "container-1", + name: "kandev-agent-test", + image: "kandev/multi-agent:latest", + state: "running", + status: "Up 5 seconds", + labels, + }; +} + +describe("containerTaskLabel", () => { + it("prefers task title over task id", () => { + expect( + containerTaskLabel( + container({ + "kandev.task_id": taskID, + "kandev.task_title": "Fix Docker reuse", + }), + ), + ).toBe("Fix Docker reuse"); + }); + + it("falls back to task id for older containers", () => { + expect(containerTaskLabel(container({ "kandev.task_id": taskID }))).toBe(taskID); + }); + + it("falls back to task id when the task title label is empty", () => { + expect( + containerTaskLabel( + container({ + "kandev.task_id": taskID, + "kandev.task_title": " ", + }), + ), + ).toBe(taskID); + }); +}); diff --git a/apps/web/components/settings/profile-edit/docker-sections.tsx b/apps/web/components/settings/profile-edit/docker-sections.tsx index 0ea06be936..18eb023ef3 100644 --- a/apps/web/components/settings/profile-edit/docker-sections.tsx +++ b/apps/web/components/settings/profile-edit/docker-sections.tsx @@ -17,10 +17,27 @@ import { } from "@/lib/api/domains/settings-api"; import type { DockerContainer } from "@/lib/api/domains/settings-api"; -const DEFAULT_IMAGE_TAG = "kandev/agent:latest"; -const DEFAULT_DOCKERFILE = "FROM ubuntu:24.04\n"; +const DEFAULT_IMAGE_TAG = "kandev/multi-agent:latest"; +// Self-contained default that produces a working image: +// - node + npm/npx so ACP agents (Claude, Codex, OpenCode, Auggie) can be +// fetched on demand at runtime +// - git so the prepare script can clone the workspace into the container +// - ca-certificates + curl as a baseline for any extra tooling users add +// +// The kandev backend mounts the agentctl binary into /usr/local/bin/agentctl +// at container creation time, so users do NOT need to bake it in here. +const DEFAULT_DOCKERFILE = `FROM node:22-slim -type BuildStatus = "idle" | "building" | "success" | "failed"; +RUN apt-get update \\ + && apt-get install -y --no-install-recommends \\ + git ca-certificates curl \\ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace +`; + +export type BuildStatus = "idle" | "building" | "success" | "failed"; +export type DockerBuildSuccess = { dockerfile: string; imageTag: string }; /** Parse a Docker JSON stream line into a human-readable string. */ function parseDockerLine(raw: string): string | null { @@ -87,7 +104,7 @@ async function readDockerStream( return hasError; } -function useBuildStream() { +function useBuildStream(onBuildSuccess?: (result: DockerBuildSuccess) => void) { const [buildStatus, setBuildStatus] = useState("idle"); const [buildLog, setBuildLog] = useState(""); @@ -114,14 +131,18 @@ function useBuildStream() { return; } const hasError = await readDockerStream(reader, appendLog); - setBuildStatus(hasError ? "failed" : "success"); + const nextStatus = hasError ? "failed" : "success"; + setBuildStatus(nextStatus); + if (nextStatus === "success") { + onBuildSuccess?.({ dockerfile, imageTag: tag.trim() }); + } } catch (err) { setBuildStatus("failed"); const msg = err instanceof Error ? err.message : "Unknown error"; setBuildLog((prev) => prev + `\nBuild failed: ${msg}`); } }, - [appendLog], + [appendLog, onBuildSuccess], ); return { buildStatus, buildLog, runBuild }; @@ -132,6 +153,7 @@ type DockerfileBuildCardProps = { onDockerfileChange: (v: string) => void; imageTag: string; onImageTagChange: (v: string) => void; + onBuildSuccess?: (result: DockerBuildSuccess) => void; }; export function DockerfileBuildCard({ @@ -139,8 +161,9 @@ export function DockerfileBuildCard({ onDockerfileChange, imageTag, onImageTagChange, + onBuildSuccess, }: DockerfileBuildCardProps) { - const { buildStatus, buildLog, runBuild } = useBuildStream(); + const { buildStatus, buildLog, runBuild } = useBuildStream(onBuildSuccess); const logRef = useRef(null); useEffect(() => { @@ -237,7 +260,42 @@ function ContainersEmptyState({ loading }: { loading: boolean }) { ); } - return

No running containers.

; + return

No Docker containers.

; +} + +export function useDockerProfileContainers(profileId: string, enabled = true) { + const [containers, setContainers] = useState([]); + const [loading, setLoading] = useState(enabled); + + const refresh = useCallback(async () => { + if (!enabled) { + setContainers([]); + setLoading(false); + return; + } + setLoading(true); + try { + const result = await listDockerContainers({ + labels: { "kandev.executor_profile_id": profileId }, + }); + setContainers(result.containers ?? []); + } catch { + setContainers([]); + } finally { + setLoading(false); + } + }, [enabled, profileId]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return { containers, loading, refresh }; +} + +export function containerTaskLabel(container: DockerContainer) { + const title = container.labels?.["kandev.task_title"]?.trim(); + return title || container.labels?.["kandev.task_id"] || "Untracked"; } function ContainerRow({ @@ -253,9 +311,12 @@ function ContainerRow({ }) { const isLoading = actionLoading === container.id; return ( - + {container.name} {container.image} + + {containerTaskLabel(container)} + {container.status} @@ -296,26 +357,9 @@ function ContainerRow({ } export function DockerContainersCard({ profileId }: { profileId: string }) { - const [containers, setContainers] = useState([]); - const [loading, setLoading] = useState(true); + const { containers, loading, refresh } = useDockerProfileContainers(profileId); const [actionLoading, setActionLoading] = useState(null); - const refresh = useCallback(async () => { - setLoading(true); - try { - const result = await listDockerContainers({ labels: { "kandev.profile_id": profileId } }); - setContainers(result.containers ?? []); - } catch { - setContainers([]); - } finally { - setLoading(false); - } - }, [profileId]); - - useEffect(() => { - void refresh(); - }, [refresh]); - const handleAction = useCallback( async (id: string, action: (id: string) => Promise) => { setActionLoading(id); @@ -332,7 +376,7 @@ export function DockerContainersCard({ profileId }: { profileId: string }) { return ( - Running Containers + Docker Containers Docker containers created by this profile. @@ -344,6 +388,7 @@ export function DockerContainersCard({ profileId }: { profileId: string }) { Name Image + Task Status diff --git a/apps/web/components/settings/profile-edit/profile-edit-page-chrome.tsx b/apps/web/components/settings/profile-edit/profile-edit-page-chrome.tsx index ed14239a0b..3f3333202c 100644 --- a/apps/web/components/settings/profile-edit/profile-edit-page-chrome.tsx +++ b/apps/web/components/settings/profile-edit/profile-edit-page-chrome.tsx @@ -1,9 +1,12 @@ "use client"; +import { useState } from "react"; import { useRouter } from "next/navigation"; import { IconTrash } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; +import { Checkbox } from "@kandev/ui/checkbox"; +import { Label } from "@kandev/ui/label"; import { Separator } from "@kandev/ui/separator"; import { Dialog, @@ -14,10 +17,40 @@ import { DialogTitle, } from "@kandev/ui/dialog"; import { EXECUTOR_ICON_MAP, getExecutorLabel } from "@/lib/executor-icons"; -import type { Executor } from "@/lib/types/http"; +import { RequestIndicator } from "@/components/request-indicator"; +import type { Executor, ExecutorProfile } from "@/lib/types/http"; const EXECUTORS_ROUTE = "/settings/executors"; const DefaultIcon = EXECUTOR_ICON_MAP.local; +export type SaveStatus = "idle" | "loading" | "success" | "error"; + +export function getSaveButtonLabel(status: SaveStatus) { + if (status === "success") return "Saved"; + if (status === "loading") return "Saving"; + return "Save Changes"; +} + +export function upsertExecutorProfile( + executors: Executor[], + executor: Executor, + updated: ExecutorProfile, +) { + let foundExecutor = false; + const replaceProfile = (profiles: ExecutorProfile[] = []) => { + const foundProfile = profiles.some((p) => p.id === updated.id); + if (!foundProfile) return [...profiles, updated]; + return profiles.map((p) => (p.id === updated.id ? updated : p)); + }; + + const next = executors.map((item) => { + if (item.id !== executor.id) return item; + foundExecutor = true; + return { ...item, profiles: replaceProfile(item.profiles ?? executor.profiles ?? []) }; + }); + + if (foundExecutor) return next; + return [...next, { ...executor, profiles: replaceProfile(executor.profiles ?? []) }]; +} function ExecutorTypeIcon({ type }: { type: string }) { const Icon = EXECUTOR_ICON_MAP[type] ?? DefaultIcon; @@ -62,17 +95,18 @@ export function ProfileHeader({ } export function ProfileFormActions({ - saving, + saveStatus, saveDisabled, onSave, onDelete, }: { - saving: boolean; + saveStatus: SaveStatus; saveDisabled: boolean; onSave: () => void; onDelete: () => void; }) { const router = useRouter(); + const saveLabel = getSaveButtonLabel(saveStatus); return (
-
@@ -100,26 +139,57 @@ export function DeleteProfileDialog({ onOpenChange, onDelete, deleting, + relatedDockerContainerCount = 0, }: { open: boolean; onOpenChange: (v: boolean) => void; - onDelete: () => void; + onDelete: (options?: { removeRelatedDockerContainers?: boolean }) => void; deleting: boolean; + relatedDockerContainerCount?: number; }) { + const [removeRelatedContainers, setRemoveRelatedContainers] = useState(null); + const hasRelatedContainers = relatedDockerContainerCount > 0; + const shouldRemoveRelatedContainers = hasRelatedContainers && (removeRelatedContainers ?? true); + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) setRemoveRelatedContainers(null); + onOpenChange(nextOpen); + }; + return ( - + Delete Profile Are you sure? This action cannot be undone. + {hasRelatedContainers && ( +
+

+ {relatedDockerContainerCount} related Docker{" "} + {relatedDockerContainerCount === 1 ? "container" : "containers"} will also be removed. +

+
+ setRemoveRelatedContainers(checked === true)} + /> + +
+
+ )} + ); +} + +function setMethodSelected(selectedIds: Set, methodId: string, selected: boolean) { + if (selected) { + selectedIds.add(methodId); + return; + } + selectedIds.delete(methodId); +} + function AuthStatusBadge({ choice, hasSecret }: { choice: AuthChoice; hasSecret: boolean }) { if (choice === "env" && hasSecret) { return ( diff --git a/apps/web/components/task-create-dialog-create-mode-selectors.tsx b/apps/web/components/task-create-dialog-create-mode-selectors.tsx index 05264e4a05..1793749aa4 100644 --- a/apps/web/components/task-create-dialog-create-mode-selectors.tsx +++ b/apps/web/components/task-create-dialog-create-mode-selectors.tsx @@ -22,6 +22,8 @@ type CreateModeSelectorsProps = { onAgentProfileChange: (v: string) => void; onExecutorProfileChange: (v: string) => void; workflowAgentLocked: boolean; + noCompatibleAgent: boolean; + executorProfileName: string | null; }; /** @@ -44,6 +46,8 @@ export function CreateModeSelectors(props: CreateModeSelectorsProps) { onExecutorProfileChange={props.onExecutorProfileChange} executorsLoading={props.executorsLoading} workflowAgentLocked={props.workflowAgentLocked} + noCompatibleAgent={props.noCompatibleAgent} + executorProfileName={props.executorProfileName} AgentSelectorComponent={AgentSelector} ExecutorProfileSelectorComponent={ExecutorProfileSelector} /> diff --git a/apps/web/components/task-create-dialog-footer.test.ts b/apps/web/components/task-create-dialog-footer.test.ts index 4517b86df0..21e8936837 100644 --- a/apps/web/components/task-create-dialog-footer.test.ts +++ b/apps/web/components/task-create-dialog-footer.test.ts @@ -34,6 +34,8 @@ function makeProps( workspaceId: "ws-1", effectiveWorkflowId: "wf-1", executorHint: null, + noCompatibleAgent: false, + executorProfileName: null, onCancel: () => {}, onUpdateWithoutAgent: () => {}, onCreateWithoutAgent: () => {}, @@ -98,6 +100,19 @@ describe("computeDisabledReason (start-task)", () => { it("flags missing agent profile for start-task button", () => { expect(computeDisabledReason(makeProps({ agentProfileId: "" }), KIND_START)).toBe(REASON_AGENT); }); + + it("flags no compatible agent for the selected executor profile", () => { + const reason = computeDisabledReason( + makeProps({ + agentProfileId: "", + noCompatibleAgent: true, + executorProfileName: "Docker (sandbox)", + }), + KIND_START, + ); + expect(reason).toContain("Docker (sandbox)"); + expect(reason).toContain("credentials"); + }); }); describe("computeDisabledReason (update)", () => { diff --git a/apps/web/components/task-create-dialog-footer.tsx b/apps/web/components/task-create-dialog-footer.tsx index 0e9a473463..2dd444fafb 100644 --- a/apps/web/components/task-create-dialog-footer.tsx +++ b/apps/web/components/task-create-dialog-footer.tsx @@ -227,6 +227,8 @@ export type TaskCreateDialogFooterProps = { workspaceId: string | null; effectiveWorkflowId: string | null; executorHint: string | null; + noCompatibleAgent: boolean; + executorProfileName: string | null; onCancel: () => void; onUpdateWithoutAgent: () => void; onCreateWithoutAgent: () => void; @@ -259,7 +261,8 @@ function computeBaseDisabled(props: TaskCreateDialogFooterProps) { !props.hasTitle || !props.hasRepositorySelection || !props.hasAllBranches || - missingCtx + missingCtx || + props.noCompatibleAgent ); } @@ -273,12 +276,18 @@ export const REASON_WORKFLOW = "Select a workflow"; export const REASON_AGENT = "Select an agent"; export const REASON_DESCRIPTION = "Add a session description"; +function noCompatibleAgentReason(executorProfileName: string | null): string { + const target = executorProfileName ? `“${executorProfileName}”` : "this executor"; + return `No compatible agent profile is configured for ${target}. Configure agent credentials in Settings → Executors.`; +} + function baseReason(props: TaskCreateDialogFooterProps): string | null { if (!props.hasTitle) return REASON_TITLE; if (!props.hasRepositorySelection) return REASON_REPO; if (!props.hasAllBranches) return REASON_BRANCH; if (props.isCreateMode && !props.workspaceId) return REASON_WORKSPACE; if (props.isCreateMode && !props.effectiveWorkflowId) return REASON_WORKFLOW; + if (props.noCompatibleAgent) return noCompatibleAgentReason(props.executorProfileName); return null; } @@ -287,6 +296,7 @@ function missingSessionDescription(props: TaskCreateDialogFooterProps): boolean } function sessionDefaultReason(props: TaskCreateDialogFooterProps): string | null { + if (props.noCompatibleAgent) return noCompatibleAgentReason(props.executorProfileName); if (!props.agentProfileId) return REASON_AGENT; if (missingSessionDescription(props)) return REASON_DESCRIPTION; return null; @@ -319,7 +329,13 @@ function computeFooterState(props: TaskCreateDialogFooterProps) { const blocked = Boolean(props.submitBlockedReason); const altDisabled = computeBaseDisabled(props) || blocked; const splitDisabled = altDisabled || !props.agentProfileId; - const defaultDisabled = (props.isSessionMode ? !props.agentProfileId : altDisabled) || blocked; + // Session mode previously only gated on missing agent — it ignored + // noCompatibleAgent, so a user who switched executor after picking an + // agent could still submit a known-incompatible combination. The reason + // text already surfaces noCompatibleAgentReason in this branch (see + // sessionDefaultReason), so the disable gate needs to match. + const sessionDisabled = !props.agentProfileId || props.noCompatibleAgent; + const defaultDisabled = (props.isSessionMode ? sessionDisabled : altDisabled) || blocked; const disabledReason = computeDisabledReason(props, resolveButtonKind(props, showStartTask)); diff --git a/apps/web/components/task-create-dialog-form-body.test.tsx b/apps/web/components/task-create-dialog-form-body.test.tsx index a49c504130..3a3de28e03 100644 --- a/apps/web/components/task-create-dialog-form-body.test.tsx +++ b/apps/web/components/task-create-dialog-form-body.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; -import { WorkflowSection } from "./task-create-dialog-form-body"; +import { CreateEditSelectors, WorkflowSection } from "./task-create-dialog-form-body"; vi.mock("@/components/workflow-selector-row", () => ({ WorkflowSelectorRow: ({ selectedWorkflowId }: { selectedWorkflowId: string | null }) => ( @@ -37,3 +37,34 @@ describe("WorkflowSection", () => { expect(container.textContent).toBe(""); }); }); + +describe("CreateEditSelectors", () => { + it("links credential setup to the selected executor profile", () => { + const EmptySelector = () => ; + + render( + {}} + isCreatingSession={false} + executorProfileOptions={[]} + executorProfileId="exec-profile-1" + onExecutorProfileChange={() => {}} + executorsLoading={false} + AgentSelectorComponent={EmptySelector} + ExecutorProfileSelectorComponent={EmptySelector} + workflowAgentLocked={false} + noCompatibleAgent={true} + executorProfileName="Docker" + />, + ); + + expect(screen.getByRole("link", { name: /configure credentials/i }).getAttribute("href")).toBe( + "/settings/executors/exec-profile-1", + ); + }); +}); diff --git a/apps/web/components/task-create-dialog-form-body.tsx b/apps/web/components/task-create-dialog-form-body.tsx index 81ed95fc0f..071c083635 100644 --- a/apps/web/components/task-create-dialog-form-body.tsx +++ b/apps/web/components/task-create-dialog-form-body.tsx @@ -51,6 +51,8 @@ type CreateEditSelectorsProps = { triggerClassName?: string; }>; workflowAgentLocked: boolean; + noCompatibleAgent: boolean; + executorProfileName: string | null; }; type AgentColumnProps = Pick< @@ -63,8 +65,35 @@ type AgentColumnProps = Pick< | "isCreatingSession" | "AgentSelectorComponent" | "workflowAgentLocked" + | "noCompatibleAgent" + | "executorProfileName" + | "executorProfileId" >; +function NoCompatibleAgentState({ + executorProfileName, + executorProfileId, +}: { + executorProfileName: string | null; + executorProfileId: string; +}) { + const target = executorProfileName ? `“${executorProfileName}”` : "this executor"; + const href = executorProfileId + ? `/settings/executors/${executorProfileId}` + : "/settings/executors"; + return ( +
+ No compatible agent profiles for {target}. + + Configure credentials + +
+ ); +} + function AgentColumn({ agentProfiles, agentProfilesLoading, @@ -74,10 +103,16 @@ function AgentColumn({ isCreatingSession, AgentSelectorComponent, workflowAgentLocked, + noCompatibleAgent, + executorProfileName, + executorProfileId, }: AgentColumnProps) { if (agentProfiles.length === 0 && !agentProfilesLoading) { return ( -
+
No agents found. Add agent @@ -85,6 +120,14 @@ function AgentColumn({
); } + if (noCompatibleAgent && !agentProfilesLoading) { + return ( + + ); + } const placeholder = agentProfilesLoading ? "Loading agents..." : "Select agent"; return ( <> diff --git a/apps/web/components/task-create-dialog-options.test.tsx b/apps/web/components/task-create-dialog-options.test.tsx index bfe5caf201..784559752c 100644 --- a/apps/web/components/task-create-dialog-options.test.tsx +++ b/apps/web/components/task-create-dialog-options.test.tsx @@ -9,10 +9,17 @@ function exec(id: string, type: Executor["type"]): Executor { const WORKTREE_SINGLE = "A git worktree will be created from the base branch."; const WORKTREE_MULTI = "A git worktree will be created for each repository in a parent folder. The agent runs in that parent folder so it can see every worktree side by side."; +const DOCKER = + "A Docker container will be created from the selected base branch and checked out on a task branch."; const LOCAL = "The agent will run directly on the repository."; describe("computeExecutorHint", () => { - const executors = [exec("wt", "worktree"), exec("loc", "local")]; + const executors = [ + exec("wt", "worktree"), + exec("loc", "local"), + exec("docker", "local_docker"), + exec("remote-docker", "remote_docker"), + ]; it("returns the multi-repo worktree hint when more than one repo is selected", () => { expect(computeExecutorHint(executors, "wt", 2)).toBe(WORKTREE_MULTI); @@ -22,6 +29,11 @@ describe("computeExecutorHint", () => { expect(computeExecutorHint(executors, "wt", 1)).toBe(WORKTREE_SINGLE); }); + it("explains that Docker profiles create an isolated task branch", () => { + expect(computeExecutorHint(executors, "docker", 1)).toBe(DOCKER); + expect(computeExecutorHint(executors, "remote-docker", 1)).toBe(DOCKER); + }); + it("returns the local hint regardless of repoCount", () => { expect(computeExecutorHint(executors, "loc", 1)).toBe(LOCAL); expect(computeExecutorHint(executors, "loc", 5)).toBe(LOCAL); diff --git a/apps/web/components/task-create-dialog-options.tsx b/apps/web/components/task-create-dialog-options.tsx index be83b08389..0f21a48069 100644 --- a/apps/web/components/task-create-dialog-options.tsx +++ b/apps/web/components/task-create-dialog-options.tsx @@ -194,6 +194,9 @@ export function computeExecutorHint( } return "A git worktree will be created from the base branch."; } + if (selectedExecutor?.type === "local_docker" || selectedExecutor?.type === "remote_docker") { + return "A Docker container will be created from the selected base branch and checked out on a task branch."; + } if (selectedExecutor?.type === "local") return "The agent will run directly on the repository."; return null; } diff --git a/apps/web/components/task-create-dialog-prop-builders.test.ts b/apps/web/components/task-create-dialog-prop-builders.test.ts new file mode 100644 index 0000000000..a57f997beb --- /dev/null +++ b/apps/web/components/task-create-dialog-prop-builders.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { computeHasAllBranches } from "./task-create-dialog-prop-builders"; +import type { DialogFormState } from "@/components/task-create-dialog-types"; + +// Minimal fs stub for computeHasAllBranches. The function only reads +// `noRepository`, `useGitHubUrl`, `githubBranch`, and `repositories[].branch`, +// so we cast a partial through `unknown` to avoid having to materialise the +// full DialogFormState surface in tests. +function fsStub(overrides: { + noRepository?: boolean; + useGitHubUrl?: boolean; + githubBranch?: string; + repositories?: Array<{ branch?: string }>; +}): DialogFormState { + return { + noRepository: false, + useGitHubUrl: false, + githubBranch: "", + repositories: [], + ...overrides, + } as unknown as DialogFormState; +} + +describe("computeHasAllBranches", () => { + it("returns true when the task is in no-repository mode (short-circuits the rest)", () => { + // noRepository mode doesn't need a branch — the backend skips the + // worktree/clone step entirely, so it always satisfies hasAllBranches. + expect(computeHasAllBranches(fsStub({ noRepository: true, repositories: [] }))).toBe(true); + }); + + it("treats the GitHub-URL mode as branched iff githubBranch is non-empty", () => { + expect(computeHasAllBranches(fsStub({ useGitHubUrl: true, githubBranch: "" }))).toBe(false); + expect(computeHasAllBranches(fsStub({ useGitHubUrl: true, githubBranch: "main" }))).toBe(true); + }); + + it("requires every selected repository row to carry a branch", () => { + expect(computeHasAllBranches(fsStub({ repositories: [] }))).toBe(false); + expect(computeHasAllBranches(fsStub({ repositories: [{ branch: "main" }] }))).toBe(true); + expect( + computeHasAllBranches(fsStub({ repositories: [{ branch: "main" }, { branch: "" }] })), + ).toBe(false); + expect( + computeHasAllBranches( + fsStub({ repositories: [{ branch: "main" }, { branch: "feature/x" }] }), + ), + ).toBe(true); + }); +}); diff --git a/apps/web/components/task-create-dialog-prop-builders.ts b/apps/web/components/task-create-dialog-prop-builders.ts new file mode 100644 index 0000000000..f381f18101 --- /dev/null +++ b/apps/web/components/task-create-dialog-prop-builders.ts @@ -0,0 +1,106 @@ +/** + * Pure prop-assembly helpers for the task-create dialog. Extracted from + * task-create-dialog.tsx so the orchestrator stays under the per-file line + * cap — these are non-React, no-JSX projections from the setup hook's + * result + dialog props, so they belong outside the component module. + */ +// Both names are used only in type positions (interface + `typeof` in +// ReturnType<>), so the `import type` form makes the otherwise-circular +// dependency with task-create-dialog.tsx explicitly type-only — bundlers +// and analysis tools won't treat it as a real runtime cycle. +import type { + TaskCreateDialogProps, + useTaskCreateDialogSetup, +} from "@/components/task-create-dialog"; +import type { DialogFormBodyProps, DialogFormState } from "@/components/task-create-dialog-types"; + +export function computeHasAllBranches(fs: DialogFormState): boolean { + if (fs.noRepository) return true; + if (fs.useGitHubUrl) return !!fs.githubBranch; + return fs.repositories.length > 0 && fs.repositories.every((r) => !!r.branch); +} + +export function buildDialogFormBodyProps( + setup: ReturnType, + props: TaskCreateDialogProps, +): DialogFormBodyProps { + const { fs, computed, handlers } = setup; + const repoLocked = !!props.lockedFields?.repository; + return { + isSessionMode: setup.isSessionMode, + isCreateMode: setup.isCreateMode, + isEditMode: setup.isEditMode, + isTaskStarted: setup.isTaskStarted, + onTaskNameChange: handlers.handleTaskNameChange, + onRowRepositoryChange: handlers.handleRowRepositoryChange, + onRowBranchChange: handlers.handleRowBranchChange, + isPassthroughProfile: computed.isPassthroughProfile, + initialDescription: fs.currentDefaults.description, + hasDescription: fs.hasDescription, + workspaceId: props.workspaceId, + onJiraImport: setup.handleJiraImport, + onLinearImport: setup.handleLinearImport, + agentProfileOptions: computed.agentProfileOptions, + executorProfileOptions: computed.executorProfileOptions, + agentProfiles: setup.agentProfiles, + agentProfilesLoading: computed.agentProfilesLoading, + executorsLoading: computed.executorsLoading, + isCreatingSession: fs.isCreatingSession, + workflows: setup.workflows, + snapshots: setup.snapshots, + effectiveWorkflowId: computed.effectiveWorkflowId ?? null, + fs, + handleKeyDown: setup.handleKeyDown, + onAgentProfileChange: handlers.handleAgentProfileChange, + onExecutorProfileChange: handlers.handleExecutorProfileChange, + onWorkflowChange: handlers.handleWorkflowChange, + onToggleGitHubUrl: repoLocked ? undefined : handlers.handleToggleGitHubUrl, + onGitHubUrlChange: handlers.handleGitHubUrlChange, + onToggleFreshBranch: handlers.handleToggleFreshBranch, + onToggleNoRepository: repoLocked ? undefined : handlers.handleToggleNoRepository, + onWorkspacePathChange: handlers.handleWorkspacePathChange, + enhance: setup.enhance, + workflowAgentLocked: computed.workflowAgentLocked, + repositories: setup.repositories, + freshBranchAvailable: setup.freshBranchAvailable, + isLocalExecutor: computed.isLocalExecutor, + noCompatibleAgent: computed.noCompatibleAgent, + executorProfileName: computed.selectedExecutorProfileName, + extraFormSlot: props.extraFormSlot, + aboveDescriptionSlot: props.aboveDescriptionSlot, + bottomSlot: props.bottomSlot, + descriptionPlaceholder: props.descriptionPlaceholder, + workflowLocked: props.lockedFields?.workflow, + }; +} + +export function buildDialogFooterProps( + setup: ReturnType, + props: TaskCreateDialogProps, +) { + const { fs, computed, submitHandlers } = setup; + return { + isSessionMode: setup.isSessionMode, + isCreateMode: setup.isCreateMode, + isEditMode: setup.isEditMode, + isTaskStarted: setup.isTaskStarted, + isPassthroughProfile: computed.isPassthroughProfile, + isCreatingSession: fs.isCreatingSession, + isCreatingTask: fs.isCreatingTask, + hasTitle: fs.hasTitle, + hasDescription: fs.hasDescription, + hasRepositorySelection: computed.hasRepositorySelection, + hasAllBranches: computeHasAllBranches(fs), + agentProfileId: computed.effectiveAgentProfileId, + workspaceId: props.workspaceId, + effectiveWorkflowId: computed.effectiveWorkflowId ?? null, + executorHint: computed.executorHint, + noCompatibleAgent: computed.noCompatibleAgent, + executorProfileName: computed.selectedExecutorProfileName, + onCancel: submitHandlers.handleCancel, + onUpdateWithoutAgent: submitHandlers.handleUpdateWithoutAgent, + onCreateWithoutAgent: submitHandlers.handleCreateWithoutAgent, + onCreateWithPlanMode: submitHandlers.handleCreateWithPlanMode, + submitBlockedReason: props.submitBlockedReason, + }; +} diff --git a/apps/web/components/task-create-dialog-state.ts b/apps/web/components/task-create-dialog-state.ts index 273e2cfa1b..2757148644 100644 --- a/apps/web/components/task-create-dialog-state.ts +++ b/apps/web/components/task-create-dialog-state.ts @@ -68,6 +68,8 @@ import { computeDialogDefaultStepId, computeSingleWorkflowFallbackId, } from "@/components/task-create-dialog-defaults"; +import { useRemoteAuthSpecs } from "@/hooks/domains/settings/use-remote-auth-specs"; +import { isAgentConfiguredOnExecutor } from "@/lib/agent-executor-compat"; export type { StepType, @@ -531,7 +533,6 @@ export function useDialogComputed({ // pill loads branches per-repo). Keep the computed value but always feed it // the URL branches when in URL mode. const branchOptions = useBranchOptions(fs.useGitHubUrl ? fs.githubBranches : []); - const agentProfileOptions = useAgentProfileOptions(agentProfiles); const allExecutorProfiles = useMemo(() => { return executors.flatMap((executor) => (executor.profiles ?? []).map((p) => ({ @@ -547,9 +548,23 @@ export function useDialogComputed({ // keep the full executor catalogue. const selectedRepoCount = fs.repositories.filter((r) => r.repositoryId || r.localPath).length; const isMultiRepoSelection = selectedRepoCount > 1; - const executorProfileOptions = useExecutorProfileOptions(allExecutorProfiles, { - disabledReasonFor: pickExecutorDisabledReason(fs.noRepository, isMultiRepoSelection), - }); + // `pickExecutorDisabledReason` came from main — folds two disable rules + // (no-repository disables worktree; multi-repo disables non-worktree) into + // one resolver. Plug it into our existing useExecutorProfileCompat wrapper + // so the downstream consumer still gets selectedExecutorProfile / + // noCompatibleAgent metadata. + // Use the effective agent ID (form value OR the workflow-locked override) + // so the compatibility gate catches the override case too — passing the + // raw fs.agentProfileId would let workflow-locked sessions slip past with + // an empty selection. + const exec = useExecutorProfileCompat( + allExecutorProfiles, + fs.executorProfileId, + effectiveAgentProfileId, + agentProfiles, + pickExecutorDisabledReason(fs.noRepository, isMultiRepoSelection), + ); + const agentProfileOptions = useAgentProfileOptions(exec.compatibleAgentProfiles); const executorHint = useExecutorHint(executors, fs.executorId, selectedRepoCount); const isLocalExecutor = useIsLocalExecutor(executors, fs.executorId); const { headerRepositoryOptions } = useRepositoryOptions(repositories, fs.discoveredRepositories); @@ -569,7 +584,7 @@ export function useDialogComputed({ hasRepositorySelection, branchOptions, agentProfileOptions, - executorProfileOptions, + executorProfileOptions: exec.executorProfileOptions, executorHint, isLocalExecutor, headerRepositoryOptions, @@ -578,6 +593,49 @@ export function useDialogComputed({ workflowAgentLocked, workflowAgentProfileId, effectiveAgentProfileId, + selectedExecutorProfileName: exec.selectedExecutorProfile?.name ?? null, + noCompatibleAgent: exec.noCompatibleAgent, + }; +} + +function useExecutorProfileCompat( + allExecutorProfiles: ExecutorProfile[], + selectedProfileId: string, + selectedAgentProfileId: string, + agentProfiles: DialogComputedArgs["agentProfiles"], + disabledReasonFor?: (profile: ExecutorProfile) => string | null, +) { + const executorProfileOptions = useExecutorProfileOptions(allExecutorProfiles, { + disabledReasonFor, + }); + const selectedExecutorProfile = useMemo( + () => allExecutorProfiles.find((p) => p.id === selectedProfileId) ?? null, + [allExecutorProfiles, selectedProfileId], + ); + const { specs: authSpecs, loaded: authLoaded } = useRemoteAuthSpecs(); + const compatibleAgentProfiles = useMemo(() => { + if (!selectedExecutorProfile || !authLoaded) return agentProfiles; + return agentProfiles.filter((ap) => + isAgentConfiguredOnExecutor(ap, selectedExecutorProfile, authSpecs), + ); + }, [agentProfiles, selectedExecutorProfile, authSpecs, authLoaded]); + // `noCompatibleAgent` gates the submit button. It must catch BOTH cases: + // 1. The selected executor has no compatible agents at all. + // 2. The user picked an agent that isn't compatible with the executor + // (e.g. switched executor after the agent was chosen). + // Previously this only checked case 1, so case 2 silently let the user + // submit with a known-incompatible combination. + const noCompatibleAgent = useMemo(() => { + if (!selectedExecutorProfile) return false; + if (compatibleAgentProfiles.length === 0) return true; + if (!selectedAgentProfileId) return false; + return !compatibleAgentProfiles.some((ap) => ap.id === selectedAgentProfileId); + }, [selectedExecutorProfile, compatibleAgentProfiles, selectedAgentProfileId]); + return { + selectedExecutorProfile, + compatibleAgentProfiles, + executorProfileOptions, + noCompatibleAgent, }; } diff --git a/apps/web/components/task-create-dialog-types.ts b/apps/web/components/task-create-dialog-types.ts index f35eae9f5d..08f99f5f24 100644 --- a/apps/web/components/task-create-dialog-types.ts +++ b/apps/web/components/task-create-dialog-types.ts @@ -7,7 +7,11 @@ import type { Task, } from "@/lib/types/http"; import type { AgentProfileOption } from "@/lib/state/slices"; -import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; +import type { + KanbanMultiState, + WorkflowSnapshotData, + WorkflowsState, +} from "@/lib/state/slices/kanban/types"; import type { useRepositoryOptions, useBranchOptions, @@ -85,6 +89,10 @@ export type DialogComputedValues = { workflowAgentProfileId: string; /** User selection if any, else the workflow override; what footer/submit/passthrough should consult */ effectiveAgentProfileId: string; + /** Display name of the currently selected executor profile (null if none). */ + selectedExecutorProfileName: string | null; + /** True when an executor profile is selected and no agent profile is compatible with it. */ + noCompatibleAgent: boolean; }; export type DialogComputedArgs = { @@ -284,3 +292,77 @@ export type SubmitHandlersDeps = { */ transformDescriptionBeforeSubmit?: (description: string) => Promise | string; }; + +import type { JiraTicket } from "@/lib/types/jira"; +import type { LinearIssue } from "@/lib/types/linear"; +import type { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut"; + +/** + * Props shared by all dialog-body variants (CreateModeBody, SessionModeBody, + * DialogFormBody). Lives in this types module so task-create-dialog.tsx + * stays under the per-file line cap — the dialog file is already a thin + * orchestrator over many sibling modules and this is the largest type + * surface left in it. + */ +export type DialogFormBodyProps = { + isSessionMode: boolean; + isCreateMode: boolean; + isEditMode: boolean; + isTaskStarted: boolean; + isPassthroughProfile: boolean; + initialDescription: string; + hasDescription: boolean; + workspaceId: string | null; + onJiraImport?: (ticket: JiraTicket) => void; + onLinearImport?: (issue: LinearIssue) => void; + agentProfileOptions: ReturnType; + executorProfileOptions: Array<{ + value: string; + label: string; + renderLabel?: () => React.ReactNode; + }>; + agentProfiles: AgentProfileOption[]; + agentProfilesLoading: boolean; + executorsLoading: boolean; + isCreatingSession: boolean; + workflows: WorkflowsState["items"]; + snapshots: KanbanMultiState["snapshots"]; + effectiveWorkflowId: string | null; + fs: DialogFormState; + handleKeyDown: ReturnType; + onTaskNameChange: (v: string) => void; + onRowRepositoryChange: (key: string, value: string) => void; + onRowBranchChange: (key: string, value: string) => void; + onAgentProfileChange: (v: string) => void; + onExecutorProfileChange: (v: string) => void; + onWorkflowChange: (v: string) => void; + onToggleGitHubUrl?: () => void; + onGitHubUrlChange: (v: string) => void; + onToggleFreshBranch: (enabled: boolean) => void; + onToggleNoRepository?: () => void; + onWorkspacePathChange: (value: string) => void; + enhance?: { onEnhance: () => void; isLoading: boolean; isConfigured: boolean }; + workflowAgentLocked: boolean; + /** Workspace repositories — driven into the chip row for repo + branch picks. */ + repositories: Repository[]; + /** Computed in the parent: single-row + local executor + not URL mode. */ + freshBranchAvailable: boolean; + /** + * True when the selected executor profile runs locally on the host. Used + * to lock the per-row branch pill (the user's checkout dictates the + * branch for local execution; fresh-branch mode unlocks it). + */ + isLocalExecutor: boolean; + noCompatibleAgent: boolean; + executorProfileName: string | null; + /** Optional render slot above the description editor. */ + aboveDescriptionSlot?: React.ReactNode; + /** Optional render slot inside the dialog body (rendered above the chip row). */ + extraFormSlot?: React.ReactNode; + /** Optional render slot at the bottom of the dialog body (above the footer). */ + bottomSlot?: React.ReactNode; + /** Optional override for the description placeholder. */ + descriptionPlaceholder?: string; + /** When true, hides the workflow picker so the enforced workflow can't be swapped. */ + workflowLocked?: boolean; +}; diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index f1d6e3fed9..bf15108dbb 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -5,7 +5,6 @@ import type { JiraTicket } from "@/lib/types/jira"; import type { LinearIssue } from "@/lib/types/linear"; import { Dialog, DialogContent, DialogHeader, DialogFooter } from "@kandev/ui/dialog"; import type { Task, Repository } from "@/lib/types/http"; -import type { AgentProfileOption } from "@/lib/state/slices"; import { SHORTCUTS } from "@/lib/keyboard/constants"; import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured"; import { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut"; @@ -18,7 +17,6 @@ import { WorkflowSection, DialogPromptSection, } from "@/components/task-create-dialog-form-body"; -import { useAgentProfileOptions } from "@/components/task-create-dialog-options"; import { AgentSelector, ExecutorProfileSelector, @@ -39,8 +37,13 @@ import { type DialogFormState, type TaskCreateDialogInitialValues, } from "@/components/task-create-dialog-state"; +import type { DialogFormBodyProps } from "@/components/task-create-dialog-types"; +import { + buildDialogFooterProps, + buildDialogFormBodyProps, +} from "@/components/task-create-dialog-prop-builders"; -interface TaskCreateDialogProps { +export interface TaskCreateDialogProps { open: boolean; onOpenChange: (open: boolean) => void; mode?: "create" | "edit" | "session"; @@ -98,73 +101,6 @@ interface TaskCreateDialogProps { submitBlockedReason?: string | null; } -type DialogFormBodyProps = { - isSessionMode: boolean; - isCreateMode: boolean; - isEditMode: boolean; - isTaskStarted: boolean; - isPassthroughProfile: boolean; - initialDescription: string; - hasDescription: boolean; - workspaceId: string | null; - onJiraImport?: (ticket: JiraTicket) => void; - onLinearImport?: (issue: LinearIssue) => void; - agentProfileOptions: ReturnType; - executorProfileOptions: Array<{ - value: string; - label: string; - renderLabel?: () => React.ReactNode; - }>; - agentProfiles: AgentProfileOption[]; - agentProfilesLoading: boolean; - executorsLoading: boolean; - isCreatingSession: boolean; - workflows: unknown[]; - snapshots: unknown; - effectiveWorkflowId: string | null; - fs: DialogFormState; - handleKeyDown: ReturnType; - onTaskNameChange: (v: string) => void; - onRowRepositoryChange: (key: string, value: string) => void; - onRowBranchChange: (key: string, value: string) => void; - onAgentProfileChange: (v: string) => void; - onExecutorProfileChange: (v: string) => void; - onWorkflowChange: (v: string) => void; - onToggleGitHubUrl?: () => void; - onGitHubUrlChange: (v: string) => void; - onToggleFreshBranch: (enabled: boolean) => void; - onToggleNoRepository?: () => void; - onWorkspacePathChange: (value: string) => void; - enhance?: { onEnhance: () => void; isLoading: boolean; isConfigured: boolean }; - workflowAgentLocked: boolean; - /** Workspace repositories — driven into the chip row for repo + branch picks. */ - repositories: Repository[]; - /** Computed in the parent: single-row + local executor + not URL mode. */ - freshBranchAvailable: boolean; - /** - * True when the selected executor profile runs locally on the host. Used - * to lock the per-row branch pill (the user's checkout dictates the - * branch for local execution; fresh-branch mode unlocks it). - */ - isLocalExecutor: boolean; - /** Optional render slot above the description editor. */ - aboveDescriptionSlot?: React.ReactNode; - /** Optional render slot inside the dialog body (rendered above the chip row). */ - extraFormSlot?: React.ReactNode; - /** Optional render slot at the bottom of the dialog body (above the footer). */ - bottomSlot?: React.ReactNode; - /** Optional override for the description placeholder. */ - descriptionPlaceholder?: string; - /** When true, hides the workflow picker so the enforced workflow can't be swapped. */ - workflowLocked?: boolean; -}; - -function computeHasAllBranches(fs: DialogFormState): boolean { - if (fs.noRepository) return true; - if (fs.useGitHubUrl) return !!fs.githubBranch; - return fs.repositories.length > 0 && fs.repositories.every((r) => !!r.branch); -} - function CreateModeBody(props: DialogFormBodyProps) { const { isCreateMode, @@ -249,6 +185,8 @@ function CreateModeBody(props: DialogFormBodyProps) { onAgentProfileChange={onAgentProfileChange} onExecutorProfileChange={onExecutorProfileChange} workflowAgentLocked={workflowAgentLocked} + noCompatibleAgent={props.noCompatibleAgent} + executorProfileName={props.executorProfileName} /> {props.bottomSlot} @@ -438,7 +376,7 @@ function resolveSingleRowLocalPath(fs: DialogFormState, repositories: Repository return ""; } -function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { +export function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { const { open, mode = "create", workspaceId, workflowId, defaultStepId } = props; const { editingTask, initialValues } = props; const isSessionMode = mode === "session"; @@ -536,97 +474,25 @@ function useGuardedSubmit( } export function TaskCreateDialog(props: TaskCreateDialogProps) { - const { open, onOpenChange, initialValues, workspaceId } = props; const setup = useTaskCreateDialogSetup(props); - const { fs, isSessionMode, isEditMode, isCreateMode, isTaskStarted } = setup; - const { sessionRepoName, workflows, agentProfiles, snapshots, repositories } = setup; - const { computed, handlers, handleKeyDown, freshBranchAvailable } = setup; - const repoLocked = !!props.lockedFields?.repository; - const { handleUpdateWithoutAgent, handleCreateWithoutAgent } = setup.submitHandlers; - const { handleCreateWithPlanMode, handleCancel } = setup.submitHandlers; - const { handleJiraImport, handleLinearImport, guardedHandleSubmit } = setup; return ( - + -
- + + - + diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index 2f1a2a3360..3ea52b848f 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -23,6 +23,7 @@ import { formatPlanCommentsAsMarkdown, } from "@/lib/state/slices/comments/format"; import { usePlanActions } from "@/hooks/domains/kanban/use-plan-actions"; +import { useExecutorEnvironmentAvailability } from "@/hooks/domains/session/use-executor-environment-availability"; import { useArchiveAndSwitchTask } from "@/hooks/use-task-actions"; import { useToast } from "@/components/toast-provider"; import type { DiffComment } from "@/lib/diff/types"; @@ -338,6 +339,14 @@ type ChatInputAreaProps = { placeholderOverride?: string; }; +function useExecutorUnavailable(taskId: string | null, sessionId: string | null) { + const availability = useExecutorEnvironmentAvailability(taskId, Boolean(sessionId && taskId)); + return { + unavailable: availability.unavailable, + reason: availability.status?.label, + }; +} + export function ChatInputArea({ chatInputRef, clarificationKey, @@ -371,6 +380,7 @@ export function ChatInputArea({ chatInputRef, }); const hasClarification = !!panelState.pendingClarification; + const executor = useExecutorUnavailable(taskId, resolvedSessionId); const placeholder = isMoving ? "Switching agent..." : (placeholderOverride ?? @@ -405,6 +415,7 @@ export function ChatInputArea({ onPlanModeChange={handlePlanModeChange} isAgentBusy={isAgentBusy} isStarting={panelState.isStarting} + isPreparingEnvironment={panelState.isPreparingEnvironment} isMoving={isMoving} isSending={isSending} onCancel={handleCancelTurn} @@ -421,6 +432,8 @@ export function ChatInputArea({ hasAgentCommands={!!(panelState.agentCommands && panelState.agentCommands.length > 0)} isFailed={panelState.isFailed} needsRecovery={needsRecovery} + executorUnavailable={executor.unavailable} + executorUnavailableReason={executor.reason} contextItems={panelState.contextItems} planContextEnabled={panelState.planContextEnabled} contextFiles={panelState.contextFiles} diff --git a/apps/web/components/task/chat/chat-input-body.tsx b/apps/web/components/task/chat/chat-input-body.tsx index 93724ec8be..aad579f9d1 100644 --- a/apps/web/components/task/chat/chat-input-body.tsx +++ b/apps/web/components/task/chat/chat-input-body.tsx @@ -18,6 +18,8 @@ export type ChatInputEditorAreaProps = { handleSubmitWithReset: () => void; inputPlaceholder: string; isDisabled: boolean; + submitDisabled: boolean; + submitDisabledReason?: string; hasClarification: boolean; planModeEnabled: boolean; planModeAvailable: boolean; @@ -126,7 +128,7 @@ export function ChatInputEditorArea(p: ChatInputEditorAreaProps) { const userContextCount = planContextEnabled ? Math.max(0, contextCount - 1) : contextCount; const hasContent = value.trim().length > 0 || userContextCount > 0; // Block submit while enhancing prompt, but keep editor editable for programmatic updates - const wrappedSubmit = isEnhancingPrompt ? () => {} : handleSubmitWithReset; + const wrappedSubmit = isEnhancingPrompt || p.submitDisabled ? () => {} : handleSubmitWithReset; const handleAttachFiles = useCallback(() => fileInputRef.current?.click(), [fileInputRef]); return (
@@ -167,7 +169,8 @@ export function ChatInputEditorArea(p: ChatInputEditorAreaProps) { taskDescription={taskDescription} isAgentBusy={isAgentBusy} hasContent={hasContent} - isDisabled={isDisabled} + isDisabled={p.submitDisabled} + submitDisabledReason={p.submitDisabledReason} isSending={isSending} onCancel={onCancel} onSubmit={wrappedSubmit} diff --git a/apps/web/components/task/chat/chat-input-container.tsx b/apps/web/components/task/chat/chat-input-container.tsx index 54f88dd3e9..860f4db3ac 100644 --- a/apps/web/components/task/chat/chat-input-container.tsx +++ b/apps/web/components/task/chat/chat-input-container.tsx @@ -58,6 +58,11 @@ type ChatInputContainerProps = { onPlanModeChange: (enabled: boolean) => void; isAgentBusy: boolean; isStarting: boolean; + /** True only while a containerized executor is bootstrapping (Docker + * prepare, Sprites sandbox spin-up). Distinct from the brief STARTING + * state every session — including local quick-chat — passes through; + * see useSessionState. */ + isPreparingEnvironment?: boolean; isMoving?: boolean; isSending: boolean; onCancel: () => void; @@ -72,6 +77,8 @@ type ChatInputContainerProps = { hasAgentCommands?: boolean; isFailed?: boolean; needsRecovery?: boolean; + executorUnavailable?: boolean; + executorUnavailableReason?: string; contextItems?: ContextItem[]; planContextEnabled?: boolean; contextFiles?: ContextFile[]; @@ -89,11 +96,19 @@ function FailedSessionBanner({ onShowDialog, taskId, sessionId, + message = "This agent has stopped.", + detail, + resumeLabel = "Resume", + resumingLabel = "Resuming...", }: { showDialog: boolean; onShowDialog: (open: boolean) => void; taskId: string | null; sessionId: string | null; + message?: string; + detail?: string; + resumeLabel?: string; + resumingLabel?: string; }) { const [isResuming, setIsResuming] = useState(false); @@ -126,9 +141,10 @@ function FailedSessionBanner({ <>
-
+
- This agent has stopped. + {message} + {detail && ({detail})}
{sessionId && taskId && ( @@ -144,7 +160,7 @@ function FailedSessionBanner({ disabled={isResuming || !profileExists} > - {isResuming ? "Resuming..." : "Resume"} + {isResuming ? resumingLabel : resumeLabel} @@ -169,6 +185,28 @@ function FailedSessionBanner({ } type ContainerState = ReturnType; +type NormalizedChatInputProps = ChatInputContainerProps & { + isFailed: boolean; + hasAgentCommands: boolean; + submitKey: "enter" | "cmd_enter"; + planContextEnabled: boolean; + contextFiles: ContextFile[]; + contextItems: ContextItem[]; + showRequestChangesTooltip: boolean; +}; + +function normalizeChatInputProps(p: ChatInputContainerProps): NormalizedChatInputProps { + return { + ...p, + isFailed: p.isFailed ?? false, + hasAgentCommands: p.hasAgentCommands ?? false, + submitKey: p.submitKey ?? "cmd_enter", + planContextEnabled: p.planContextEnabled ?? false, + contextFiles: p.contextFiles ?? [], + contextItems: p.contextItems ?? [], + showRequestChangesTooltip: p.showRequestChangesTooltip ?? false, + }; +} function buildContextAreaProps( s: ContainerState, @@ -189,7 +227,7 @@ type EnhancePromptExtras = { function buildEditorAreaProps( s: ContainerState, - p: ChatInputContainerProps, + p: NormalizedChatInputProps, extras: EnhancePromptExtras = {}, ): ChatInputEditorAreaProps { return { @@ -199,21 +237,23 @@ function buildEditorAreaProps( handleSubmitWithReset: s.handleSubmitWithReset, inputPlaceholder: s.inputPlaceholder, isDisabled: s.isDisabled, + submitDisabled: s.submitDisabled, + submitDisabledReason: s.submitDisabledReason, hasClarification: s.hasClarification, planModeEnabled: p.planModeEnabled, planModeAvailable: p.planModeAvailable ?? true, mcpServers: p.mcpServers ?? [], - submitKey: p.submitKey ?? "cmd_enter", + submitKey: p.submitKey, setIsInputFocused: s.setIsInputFocused, sessionId: p.sessionId, taskId: p.taskId, onAddContextFile: p.onAddContextFile, onToggleContextFile: p.onToggleContextFile, - planContextEnabled: p.planContextEnabled ?? false, + planContextEnabled: p.planContextEnabled, handleAgentCommand: s.handleAgentCommand, addFiles: s.addFiles, fileInputRef: s.fileInputRef, - showRequestChangesTooltip: p.showRequestChangesTooltip ?? false, + showRequestChangesTooltip: p.showRequestChangesTooltip, isAgentBusy: p.isAgentBusy, onPlanModeChange: p.onPlanModeChange, taskTitle: p.taskTitle, @@ -223,7 +263,7 @@ function buildEditorAreaProps( contextCount: s.allItems.length, contextPopoverOpen: s.contextPopoverOpen, setContextPopoverOpen: s.setContextPopoverOpen, - contextFiles: p.contextFiles ?? [], + contextFiles: p.contextFiles, onImplementPlan: p.onImplementPlan, onEnhancePrompt: extras.onEnhancePrompt, isEnhancingPrompt: extras.isEnhancingPrompt, @@ -234,41 +274,35 @@ function buildEditorAreaProps( }; } +function buildStoppedBannerProps(p: ChatInputContainerProps) { + if (!p.executorUnavailable) return {}; + return { + message: "Executor environment is unavailable.", + detail: p.executorUnavailableReason, + resumeLabel: "Restart", + resumingLabel: "Restarting...", + }; +} + export const ChatInputContainer = forwardRef( function ChatInputContainer(props, ref) { - const { - sessionId, - taskId, - taskTitle, - taskDescription, - isAgentBusy, - isStarting, - isMoving = false, - isSending, - isFailed = false, - showRequestChangesTooltip = false, - } = props; + const { sessionId, taskId, taskTitle, taskDescription, isAgentBusy, isStarting, isSending } = + props; + const p = normalizeChatInputProps(props); + const isMoving = props.isMoving ?? false; + const executorUnavailable = props.executorUnavailable ?? false; const isBusyVisual = isStarting || isMoving; - const p = { - ...props, - isFailed: isFailed ?? false, - hasAgentCommands: props.hasAgentCommands ?? false, - submitKey: props.submitKey ?? "cmd_enter", - planContextEnabled: props.planContextEnabled ?? false, - contextFiles: props.contextFiles ?? [], - contextItems: props.contextItems ?? [], - showRequestChangesTooltip, - } as const; - const s = useChatInputContainer({ ref, sessionId, isSending, isStarting, + isPreparingEnvironment: props.isPreparingEnvironment ?? false, isMoving, isFailed: p.isFailed, needsRecovery: props.needsRecovery ?? false, + executorUnavailable, isAgentBusy, hasAgentCommands: p.hasAgentCommands, placeholder: props.placeholder, @@ -277,7 +311,7 @@ export const ChatInputContainer = forwardRef ); } @@ -317,11 +352,11 @@ export const ChatInputContainer = forwardRef ({ TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })); +vi.mock("@/components/keyboard-shortcut-tooltip", () => ({ + KeyboardShortcutTooltip: ({ + children, + description, + }: { + children: React.ReactNode; + description?: string; + }) => ( + <> + {children} + {description ? {description} : null} + + ), +})); + import { ChatInputToolbar } from "./chat-input-toolbar"; function deferred() { @@ -95,3 +110,28 @@ describe("ChatInputToolbar cancel button", () => { expect(button.disabled).toBe(false); }); }); + +describe("ChatInputToolbar submit button", () => { + it("shows the setup-disabled reason while keeping the submit button disabled", () => { + render( + {}} + sessionId="s1" + taskId="t1" + taskDescription="" + isAgentBusy={false} + hasContent + isDisabled + submitDisabledReason="The agent is still being set up." + isSending={false} + onCancel={() => {}} + onSubmit={() => {}} + minimalToolbar + />, + ); + + expect((screen.getByTestId("submit-message-button") as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByText("The agent is still being set up.")).toBeTruthy(); + }); +}); diff --git a/apps/web/components/task/chat/chat-input-toolbar.tsx b/apps/web/components/task/chat/chat-input-toolbar.tsx index a9fc7d43b2..d6376fb3a7 100644 --- a/apps/web/components/task/chat/chat-input-toolbar.tsx +++ b/apps/web/components/task/chat/chat-input-toolbar.tsx @@ -3,7 +3,6 @@ import { memo, useCallback, useRef, useState, type ReactNode } from "react"; import { IconArrowUp, - IconChevronDown, IconChevronsLeft, IconDots, IconFileTextSpark, @@ -12,15 +11,8 @@ import { IconPlugConnected, IconPlugConnectedX, IconPaperclip, - IconPlus, - IconRocket, } from "@tabler/icons-react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@kandev/ui/dropdown-menu"; + import { EnhancePromptButton } from "@/components/enhance-prompt-button"; import { GridSpinner } from "@/components/grid-spinner"; import { Button } from "@kandev/ui/button"; @@ -38,6 +30,7 @@ import { ModelSelector } from "@/components/task/model-selector"; import { ModeSelector } from "@/components/task/mode-selector"; import { ContextPopover } from "./context-popover"; import { ResetContextButton } from "./reset-context-button"; +import { ImplementPlanButton } from "./implement-plan-button"; import type { ContextFile } from "@/lib/state/context-files-store"; export type ChatInputToolbarProps = { @@ -53,6 +46,7 @@ export type ChatInputToolbarProps = { /** Whether the input has content to send (text, comments, or context) */ hasContent?: boolean; isDisabled: boolean; + submitDisabledReason?: string; isSending: boolean; onCancel: () => void | Promise; onSubmit: () => void; @@ -85,6 +79,7 @@ type SubmitButtonProps = { isAgentBusy: boolean; hasContent: boolean; isDisabled: boolean; + submitDisabledReason?: string; isSending: boolean; planModeEnabled: boolean; onCancel: () => void | Promise; @@ -92,7 +87,12 @@ type SubmitButtonProps = { submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS]; }; -function submitTooltipDescription(isAgentBusy: boolean, planModeEnabled: boolean) { +function submitTooltipDescription( + isAgentBusy: boolean, + planModeEnabled: boolean, + submitDisabledReason?: string, +) { + if (submitDisabledReason) return submitDisabledReason; if (isAgentBusy) return "Queue message"; if (planModeEnabled) return "Request plan changes"; return undefined; @@ -102,6 +102,7 @@ function SubmitButton({ isAgentBusy, hasContent, isDisabled, + submitDisabledReason, isSending, planModeEnabled, onCancel, @@ -116,6 +117,11 @@ function SubmitButton({ // take several seconds, and without this the user clicks repeatedly and // each click hits the backend. const [isCancelling, setIsCancelling] = useState(false); + const tooltipDescription = submitTooltipDescription( + isAgentBusy, + planModeEnabled, + submitDisabledReason, + ); // Re-entrancy guard via ref: `disabled={isCancelling}` already blocks DOM // clicks, but the ref makes the guard effective for any programmatic caller // and keeps `isCancelling` out of the useCallback deps so the handler @@ -164,25 +170,27 @@ function SubmitButton({ {showSendButton && ( - + + + )}
@@ -226,61 +234,6 @@ function PlanToggleButton({ ); } -function ImplementPlanButton({ onClick }: { onClick: (fresh: boolean) => void }) { - const primary = ( - - - - - Implement the plan in this session - - ); - return ( -
- {primary} - - - - - - onClick(true)} - className="cursor-pointer" - > - -
-
Implement in fresh agent
-
- Starts a new session with a clean context window -
-
-
-
-
-
- ); -} - function McpIndicator({ mcpServers }: { mcpServers: string[] }) { const hasMcp = mcpServers.length > 0; const tooltipText = hasMcp @@ -348,6 +301,7 @@ function ToolbarRightSection({ hasContent, onImplementPlan, isDisabled, + submitDisabledReason, isSending, onCancel, onSubmit, @@ -361,6 +315,7 @@ function ToolbarRightSection({ hasContent: boolean; onImplementPlan?: (fresh: boolean) => void; isDisabled: boolean; + submitDisabledReason?: string; isSending: boolean; onCancel: () => void; onSubmit: () => void; @@ -378,6 +333,7 @@ function ToolbarRightSection({ isAgentBusy={isAgentBusy} hasContent={hasContent} isDisabled={isDisabled} + submitDisabledReason={submitDisabledReason} isSending={isSending} planModeEnabled={planModeEnabled} onCancel={onCancel} @@ -493,13 +449,21 @@ function MinimalToolbar({ isAgentBusy, hasContent, isDisabled, + submitDisabledReason, isSending, onCancel, onSubmit, submitKey = "cmd_enter", }: Pick< ChatInputToolbarProps, - "isAgentBusy" | "hasContent" | "isDisabled" | "isSending" | "onCancel" | "onSubmit" | "submitKey" + | "isAgentBusy" + | "hasContent" + | "isDisabled" + | "submitDisabledReason" + | "isSending" + | "onCancel" + | "onSubmit" + | "submitKey" >) { const submitShortcut = submitKey === "enter" ? SHORTCUTS.SUBMIT_ENTER : SHORTCUTS.SUBMIT; return ( @@ -508,6 +472,7 @@ function MinimalToolbar({ isAgentBusy={isAgentBusy} hasContent={hasContent ?? false} isDisabled={isDisabled} + submitDisabledReason={submitDisabledReason} isSending={isSending} planModeEnabled={false} onCancel={onCancel} @@ -545,6 +510,7 @@ export const ChatInputToolbar = memo(function ChatInputToolbar(rawProps: ChatInp isAgentBusy={props.isAgentBusy} hasContent={props.hasContent} isDisabled={props.isDisabled} + submitDisabledReason={props.submitDisabledReason} isSending={props.isSending} onCancel={props.onCancel} onSubmit={props.onSubmit} @@ -615,6 +581,7 @@ export const ChatInputToolbar = memo(function ChatInputToolbar(rawProps: ChatInp hasContent={props.hasContent ?? false} onImplementPlan={props.onImplementPlan} isDisabled={props.isDisabled} + submitDisabledReason={props.submitDisabledReason} isSending={props.isSending} onCancel={props.onCancel} onSubmit={props.onSubmit} diff --git a/apps/web/components/task/chat/implement-plan-button.tsx b/apps/web/components/task/chat/implement-plan-button.tsx new file mode 100644 index 0000000000..1ac7c6ed12 --- /dev/null +++ b/apps/web/components/task/chat/implement-plan-button.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { IconChevronDown, IconPlus, IconRocket } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@kandev/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; + +/** + * Split-button: primary "Implement" runs the plan inline in the current + * session; the dropdown caret offers "Implement in fresh agent" which starts + * a new session with a clean context window. `onClick(fresh)` is invoked + * with `false` for the primary path and `true` for the fresh-agent path. + */ +export function ImplementPlanButton({ onClick }: { onClick: (fresh: boolean) => void }) { + const primary = ( + + + + + Implement the plan in this session + + ); + return ( +
+ {primary} + + + + + + onClick(true)} + className="cursor-pointer" + > + +
+
Implement in fresh agent
+
+ Starts a new session with a clean context window +
+
+
+
+
+
+ ); +} diff --git a/apps/web/components/task/chat/use-chat-input-container.test.ts b/apps/web/components/task/chat/use-chat-input-container.test.ts new file mode 100644 index 0000000000..62b2d36092 --- /dev/null +++ b/apps/web/components/task/chat/use-chat-input-container.test.ts @@ -0,0 +1,57 @@ +import { createRef } from "react"; +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useChatInputContainer } from "./use-chat-input-container"; +import type { ChatInputContainerHandle } from "./chat-input-container"; + +function renderInputState(overrides: Partial[0]> = {}) { + return renderHook(() => + useChatInputContainer({ + ref: createRef(), + sessionId: "session-1", + isSending: false, + isStarting: false, + isPreparingEnvironment: false, + isMoving: false, + isFailed: false, + needsRecovery: false, + executorUnavailable: false, + isAgentBusy: false, + hasAgentCommands: true, + placeholder: undefined, + contextItems: [], + pendingClarification: null, + onClarificationResolved: undefined, + pendingCommentsByFile: undefined, + hasContextComments: false, + showRequestChangesTooltip: false, + onRequestChangesTooltipDismiss: undefined, + onSubmit: vi.fn(), + ...overrides, + }), + ); +} + +describe("useChatInputContainer", () => { + it("disables the editor while the session is still STARTING", () => { + // The editor must stay uneditable until the agent reaches RUNNING — if + // the user can press Cmd+Enter mid-startup, the backend rejects with + // "Failed to send message to agent" because the agent process isn't + // ready yet. This is the regression from earlier rounds where the e2e + // quick-chat suite kept failing on race conditions. + const { result } = renderInputState({ isStarting: true }); + + expect(result.current.isDisabled).toBe(true); + expect(result.current.submitDisabled).toBe(true); + expect(result.current.submitDisabledReason).toBeUndefined(); + }); + + it("surfaces the setup tooltip only while a container/sandbox is preparing", () => { + const { result } = renderInputState({ + isStarting: true, + isPreparingEnvironment: true, + }); + + expect(result.current.submitDisabledReason).toBe("The agent is still being set up."); + }); +}); diff --git a/apps/web/components/task/chat/use-chat-input-container.ts b/apps/web/components/task/chat/use-chat-input-container.ts index 0d68a8bd4a..5cf474e04c 100644 --- a/apps/web/components/task/chat/use-chat-input-container.ts +++ b/apps/web/components/task/chat/use-chat-input-container.ts @@ -16,9 +16,18 @@ type UseChatInputContainerParams = { sessionId: string | null; isSending: boolean; isStarting: boolean; + /** True only during a real Docker/Sprites prepare phase. Different from + * `isStarting`, which fires for every session that's transitioning + * through STARTING (including local quick-chat). Drives the "agent still + * being set up" submit-disabled tooltip so it only appears when a + * container/sandbox is genuinely bootstrapping; the disabled state + * itself is still gated on the broader `isStarting` to keep e2e + * Cmd+Enter from racing the not-yet-ready agent. */ + isPreparingEnvironment: boolean; isMoving: boolean; isFailed: boolean; needsRecovery: boolean; + executorUnavailable: boolean; isAgentBusy: boolean; hasAgentCommands: boolean; placeholder: string | undefined; @@ -59,6 +68,15 @@ function useInputHandle( ); } +function useSyncTipTapRef( + tiptapRef: React.RefObject, + inputRef: React.RefObject, +) { + useEffect(() => { + tiptapRef.current = inputRef.current; + }); +} + function getInputPlaceholder( placeholder: string | undefined, isAgentBusy: boolean, @@ -74,10 +92,12 @@ function getInputPlaceholder( function computeDerivedState(params: { isStarting: boolean; + isPreparingEnvironment: boolean; isMoving: boolean; isSending: boolean; isFailed: boolean; needsRecovery: boolean; + executorUnavailable: boolean; pendingClarification: Message | null | undefined; onClarificationResolved: (() => void) | undefined; pendingCommentsByFile: Record | undefined; @@ -87,12 +107,26 @@ function computeDerivedState(params: { isAgentBusy: boolean; hasAgentCommands: boolean; }) { + // STARTING is included so the editor itself stays uneditable until the + // session reaches RUNNING. Without this, the e2e suite's wait-for- + // contenteditable would fire mid-STARTING, the test would press Cmd+Enter, + // and the backend would reject with "Failed to send message to agent" + // before the agent process is ready to receive prompts. const isDisabled = params.isStarting || params.isMoving || params.isSending || params.isFailed || - params.needsRecovery; + params.needsRecovery || + params.executorUnavailable; + const submitDisabled = isDisabled; + // The "agent still being set up" tooltip is only meaningful while a + // container/sandbox is actively bootstrapping. The brief STARTING + // transition for local quick-chat sessions doesn't deserve its own + // tooltip — the editor is disabled, that's the signal. + const submitDisabledReason = params.isPreparingEnvironment + ? "The agent is still being set up." + : undefined; const hasClarification = !!(params.pendingClarification && params.onClarificationResolved); const hasPendingComments = !!( params.pendingCommentsByFile && Object.keys(params.pendingCommentsByFile).length > 0 @@ -107,6 +141,8 @@ function computeDerivedState(params: { ); return { isDisabled, + submitDisabled, + submitDisabledReason, hasClarification, hasPendingComments, hasContextZone, @@ -116,25 +152,11 @@ function computeDerivedState(params: { } export function useChatInputContainer(params: UseChatInputContainerParams) { - const { - ref, - sessionId, - isSending, - isStarting, - isMoving, - isFailed, - needsRecovery, - isAgentBusy, - hasAgentCommands, - placeholder, - contextItems, - pendingClarification, - onClarificationResolved, - pendingCommentsByFile, - showRequestChangesTooltip, - onRequestChangesTooltipDismiss, - onSubmit, - } = params; + const { ref, sessionId, isSending, isStarting, isPreparingEnvironment, isMoving } = params; + const { isFailed, needsRecovery, executorUnavailable, isAgentBusy, hasAgentCommands } = params; + const { placeholder, contextItems, pendingClarification, onClarificationResolved } = params; + const { pendingCommentsByFile, showRequestChangesTooltip } = params; + const { onRequestChangesTooltipDismiss, onSubmit } = params; const [isInputFocused, setIsInputFocused] = useState(false); const [showNewSessionDialog, setShowNewSessionDialog] = useState(false); @@ -159,9 +181,8 @@ export function useChatInputContainer(params: UseChatInputContainerParams) { onSubmit, }); - useEffect(() => { - tiptapRef.current = inputRef.current; - }); + useSyncTipTapRef(tiptapRef, inputRef); + useInputHandle(ref, inputRef, getAttachments); // Auto-expand the input container as the user types more lines @@ -185,10 +206,12 @@ export function useChatInputContainer(params: UseChatInputContainerParams) { const derived = computeDerivedState({ isStarting, + isPreparingEnvironment, isMoving, isSending, isFailed, needsRecovery, + executorUnavailable, pendingClarification, onClarificationResolved, pendingCommentsByFile, diff --git a/apps/web/components/task/executor-environment-info.tsx b/apps/web/components/task/executor-environment-info.tsx new file mode 100644 index 0000000000..e879ed9472 --- /dev/null +++ b/apps/web/components/task/executor-environment-info.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useMemo } from "react"; +import { IconCopy, IconLoader } from "@tabler/icons-react"; +import { Badge } from "@kandev/ui/badge"; +import { toast } from "sonner"; + +import { + type ContainerLiveStatus, + type TaskEnvironment, +} from "@/lib/api/domains/task-environment-api"; +import { resolveExecutorEnvironmentStatus, type StatusTone } from "./executor-environment-status"; + +const TONE_CLASSES: Record = { + running: "border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-300", + stopped: "border-zinc-500/30 bg-zinc-500/10 text-zinc-700 dark:text-zinc-300", + warn: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300", + error: "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300", + neutral: "border-muted text-muted-foreground", +}; + +export function EnvironmentInfo({ + env, + container, + loading, +}: { + env: TaskEnvironment | null; + container: ContainerLiveStatus | null; + loading: boolean; +}) { + if (loading && !env) { + return ( +
+ +
+ ); + } + + if (!env) { + return ( +
+

No environment yet

+

+ An environment is created when you start a session on this task. +

+
+ ); + } + + return ( +
+
+ {formatExecutorType(env.executor_type)} + +
+ +
+ ); +} + +function StatusBadge({ + env, + container, +}: { + env: TaskEnvironment; + container: ContainerLiveStatus | null; +}) { + // For container-backed envs the live state is the source of truth; for the + // others fall back to the recorded TaskEnvironment.status. + const { label, tone } = resolveExecutorEnvironmentStatus(env, container); + const className = TONE_CLASSES[tone]; + return ( + + {label} + + ); +} + +function EnvironmentFields({ + env, + container, +}: { + env: TaskEnvironment; + container: ContainerLiveStatus | null; +}) { + const fields = useMemo(() => buildFields(env, container), [env, container]); + if (fields.length === 0) { + return

No resource details available.

; + } + return ( +
+ {fields.map((f) => ( + + ))} +
+ ); +} + +function Field({ label, value, copy }: { label: string; value: string; copy?: boolean }) { + return ( +
+
{label}
+
+ {value} + {copy && ( + + )} +
+
+ ); +} + +type FieldRow = { label: string; value: string; copy?: boolean }; + +function buildFields(env: TaskEnvironment, container: ContainerLiveStatus | null): FieldRow[] { + const rows: FieldRow[] = []; + + if (env.worktree_path) { + rows.push({ label: "Worktree", value: env.worktree_path, copy: true }); + } + if (env.worktree_branch) { + rows.push({ label: "Branch", value: env.worktree_branch, copy: true }); + } + + if (env.container_id) { + const short = env.container_id.slice(0, 12); + rows.push({ label: "Container", value: short, copy: true }); + // Use `sh` rather than `bash` — user-built images may only ship + // /bin/sh (busybox/alpine/etc.), and the bootstrap entrypoint already + // assumes sh-only. + rows.push({ + label: "Shell", + value: `docker exec -it ${short} sh`, + copy: true, + }); + if (container?.started_at && container.state === "running") { + rows.push({ label: "Uptime", value: formatUptime(container.started_at) }); + } + } + + if (env.sandbox_id) { + rows.push({ label: "Sprite", value: env.sandbox_id, copy: true }); + } + + return rows; +} + +function formatUptime(startedAt: string): string { + const startedMs = Date.parse(startedAt); + if (Number.isNaN(startedMs)) return startedAt; + const elapsedSec = Math.max(0, Math.floor((Date.now() - startedMs) / 1000)); + if (elapsedSec < 60) return `${elapsedSec}s`; + const min = Math.floor(elapsedSec / 60); + if (min < 60) return `${min}m`; + const hr = Math.floor(min / 60); + return `${hr}h ${min % 60}m`; +} + +function formatExecutorType(type: string): string { + switch (type) { + case "local_pc": + case "worktree": + return "Local (worktree)"; + case "local_docker": + return "Local Docker"; + case "sprites": + return "Sprites sandbox"; + case "remote_docker": + return "Remote Docker"; + default: + return type || "Unknown executor"; + } +} diff --git a/apps/web/components/task/executor-environment-status.test.ts b/apps/web/components/task/executor-environment-status.test.ts new file mode 100644 index 0000000000..5006572295 --- /dev/null +++ b/apps/web/components/task/executor-environment-status.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + getEnvironmentStatusSnapshot, + resolveExecutorEnvironmentStatus, +} from "./executor-environment-status"; +import type { ContainerLiveStatus, TaskEnvironment } from "@/lib/api/domains/task-environment-api"; + +const baseEnv: TaskEnvironment = { + id: "env-1", + task_id: "task-1", + repository_id: "repo-1", + executor_type: "local_docker", + executor_id: "local-docker", + executor_profile_id: "profile-1", + agent_execution_id: "exec-1", + control_port: 41001, + status: "ready", + created_at: "2026-05-02T00:00:00Z", + updated_at: "2026-05-02T00:00:00Z", +}; + +function container(overrides: Partial): ContainerLiveStatus { + return { + container_id: "container-1", + state: "running", + status: "running", + started_at: "2026-05-02T00:00:00Z", + ...overrides, + }; +} + +describe("resolveExecutorEnvironmentStatus", () => { + it("uses live container state ahead of recorded environment status", () => { + expect( + resolveExecutorEnvironmentStatus( + { ...baseEnv, status: "ready" }, + container({ state: "exited", status: "exited", exit_code: 137 }), + ), + ).toEqual({ label: "exited (137)", tone: "error" }); + }); + + it("shows paused containers as a warning state", () => { + expect(resolveExecutorEnvironmentStatus(baseEnv, container({ state: "paused" }))).toEqual({ + label: "paused", + tone: "warn", + }); + }); + + it("falls back to the persisted environment status when no live container exists", () => { + expect(resolveExecutorEnvironmentStatus({ ...baseEnv, status: "creating" }, null)).toEqual({ + label: "starting", + tone: "warn", + }); + }); +}); + +describe("getEnvironmentStatusSnapshot", () => { + it("uses a stable key for status transition notifications", () => { + expect(getEnvironmentStatusSnapshot(baseEnv, container({ state: "running" }))).toEqual({ + key: "running:running", + label: "running", + tone: "running", + }); + expect(getEnvironmentStatusSnapshot(null, null)).toEqual({ + key: "none", + label: "not created", + tone: "neutral", + }); + }); +}); diff --git a/apps/web/components/task/executor-environment-status.ts b/apps/web/components/task/executor-environment-status.ts new file mode 100644 index 0000000000..c9823b6ecd --- /dev/null +++ b/apps/web/components/task/executor-environment-status.ts @@ -0,0 +1,65 @@ +import type { ContainerLiveStatus, TaskEnvironment } from "@/lib/api/domains/task-environment-api"; + +export type StatusTone = "running" | "stopped" | "warn" | "error" | "neutral"; + +export type ExecutorEnvironmentStatus = { + label: string; + tone: StatusTone; +}; + +export type EnvironmentStatusSnapshot = ExecutorEnvironmentStatus & { + key: string; +}; + +export function getEnvironmentStatusSnapshot( + env: TaskEnvironment | null, + container: ContainerLiveStatus | null, +): EnvironmentStatusSnapshot { + if (!env) { + return { key: "none", label: "not created", tone: "neutral" }; + } + const status = resolveExecutorEnvironmentStatus(env, container); + return { ...status, key: `${status.tone}:${status.label}` }; +} + +export function resolveExecutorEnvironmentStatus( + env: TaskEnvironment, + container: ContainerLiveStatus | null, +): ExecutorEnvironmentStatus { + if (container) { + return resolveContainerStatus(container); + } + return resolveEnvStatus(env.status); +} + +const CONTAINER_STATUS_TONES: Record = { + paused: "warn", + restarting: "warn", + dead: "error", +}; + +function resolveContainerStatus(container: ContainerLiveStatus): ExecutorEnvironmentStatus { + if (container.missing) return { label: "missing", tone: "warn" }; + if (container.state === "running") { + return { label: container.status || "running", tone: "running" }; + } + if (container.state === "exited") { + return { + label: container.exit_code ? `exited (${container.exit_code})` : "exited", + tone: container.exit_code ? "error" : "stopped", + }; + } + const tone = CONTAINER_STATUS_TONES[container.state] ?? "neutral"; + return { label: container.state || "unknown", tone }; +} + +const ENV_STATUS_MAP: Record = { + ready: { label: "ready", tone: "running" }, + creating: { label: "starting", tone: "warn" }, + stopped: { label: "stopped", tone: "stopped" }, + failed: { label: "failed", tone: "error" }, +}; + +function resolveEnvStatus(status: string): ExecutorEnvironmentStatus { + return ENV_STATUS_MAP[status] ?? { label: status || "unknown", tone: "neutral" }; +} diff --git a/apps/web/components/task/executor-prepare-status.tsx b/apps/web/components/task/executor-prepare-status.tsx new file mode 100644 index 0000000000..8db9be7db3 --- /dev/null +++ b/apps/web/components/task/executor-prepare-status.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { IconAlertTriangle, IconLoader2 } from "@tabler/icons-react"; + +import type { PrepareSummary } from "@/lib/prepare/summarize"; + +export function PrepareStatusSection({ summary }: { summary: PrepareSummary }) { + if (summary.phase === "idle" || summary.phase === "ready") return null; + if (summary.phase === "resuming") return ; + if (summary.phase === "preparing" || summary.phase === "preparing_fallback") { + return ; + } + if (summary.phase === "failed") return ; + return null; +} + +function ResumingRow() { + return ( +
+
+ + Resuming session +
+
Reconnecting to the existing environment…
+
+ ); +} + +function PreparingRow({ summary }: { summary: PrepareSummary }) { + const stepLabel = summary.current?.name ?? "Preparing..."; + const stepNumber = Math.min(summary.currentIndex + 1, summary.totalSteps); + return ( +
+
+ + Preparing environment +
+
+ {summary.totalSteps > 0 + ? `Step ${stepNumber} of ${summary.totalSteps}: ${stepLabel}` + : stepLabel} +
+ {summary.phase === "preparing_fallback" && summary.fallbackWarning && ( +
+ + {summary.fallbackWarning} +
+ )} +
+ ); +} + +function FailedRow({ summary }: { summary: PrepareSummary }) { + return ( +
+
+ + Environment preparation failed +
+ {summary.failedStep && ( +
Failed at: {summary.failedStep.name}
+ )} + {summary.failedStep?.error && ( +
+          {summary.failedStep.error}
+        
+ )} +
+ ); +} diff --git a/apps/web/components/task/executor-settings-button.test.tsx b/apps/web/components/task/executor-settings-button.test.tsx new file mode 100644 index 0000000000..3d3218ab27 --- /dev/null +++ b/apps/web/components/task/executor-settings-button.test.tsx @@ -0,0 +1,168 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { SessionPrepareState } from "@/lib/state/slices/session-runtime/types"; + +// Constants declared *above* the vi.mock factories so the mocks can reference +// them without relying on vitest's hoisting capturing TDZ-undefined closures. +// The previous layout worked by accident (factories aren't invoked until the +// test imports the module under test, by which time the const initialization +// has run), but a refactor that imports the module earlier — for example, a +// shared test helper — would break the closure silently. +const SESSION_ID = "session-1"; +const TASK_ID = "task-1"; +const STEP_CREATE_SANDBOX = "Creating cloud sandbox"; +const PREPARE_STATUS_TESTID = "executor-prepare-status"; +const SETTINGS_BUTTON_TESTID = "executor-settings-button"; + +let mockPrepareState: SessionPrepareState | null = null; +let mockSessionState: string | null = null; +let mockEnv: { executor_type: string; sandbox_id?: string; container_id?: string } | null = null; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + prepareProgress: { + bySessionId: mockPrepareState ? { [mockPrepareState.sessionId]: mockPrepareState } : {}, + }, + taskSessions: { + items: mockSessionState + ? { [SESSION_ID]: { id: SESSION_ID, state: mockSessionState } } + : {}, + }, + }), +})); + +vi.mock("@/lib/api/domains/task-environment-api", () => ({ + fetchTaskEnvironmentLive: vi.fn().mockImplementation(async () => ({ + environment: mockEnv ?? { executor_type: "" }, + container: null, + })), + resetTaskEnvironment: vi.fn().mockResolvedValue({ success: true }), +})); + +vi.mock("./task-reset-env-confirm-dialog", () => ({ + TaskResetEnvConfirmDialog: () => null, +})); + +import { ExecutorSettingsButton } from "./executor-settings-button"; + +describe("ExecutorSettingsButton", () => { + afterEach(() => { + cleanup(); + mockPrepareState = null; + mockSessionState = null; + mockEnv = null; + }); + + it("shows the cloud icon when the executor type is sprites", async () => { + mockEnv = { executor_type: "sprites", sandbox_id: "kandev-abc" }; + + render(); + + // Wait a tick for the live fetch to resolve. + await Promise.resolve(); + await Promise.resolve(); + expect(await screen.findByTestId("executor-status-cloud-icon")).toBeTruthy(); + }); + + it("shows the container icon for both docker variants", async () => { + mockEnv = { executor_type: "local_docker", container_id: "abcdef" }; + + render(); + + await Promise.resolve(); + expect(await screen.findByTestId("executor-status-container-icon")).toBeTruthy(); + }); + + it("swaps to a spinner while the prepare progress is preparing", async () => { + mockEnv = { executor_type: "sprites", sandbox_id: "kandev-abc" }; + mockPrepareState = { + sessionId: SESSION_ID, + status: "preparing", + steps: [{ name: STEP_CREATE_SANDBOX, status: "running" }], + }; + + render(); + + expect(screen.getByTestId("executor-settings-button-spinner")).toBeTruthy(); + expect(screen.queryByTestId("executor-status-cloud-icon")).toBeNull(); + }); + + it("renders the preparing section with current step copy", async () => { + mockPrepareState = { + sessionId: SESSION_ID, + status: "preparing", + steps: [ + { name: STEP_CREATE_SANDBOX, status: "completed" }, + { name: "Uploading agent controller", status: "running" }, + { name: "Waiting for agent controller", status: "pending" }, + ], + }; + + render(); + + // Open the hover card by hovering the trigger (not clicking — it's a + // borderless info surface, not a button). + fireEvent.pointerEnter(screen.getByTestId(SETTINGS_BUTTON_TESTID)); + + expect(await screen.findByTestId(PREPARE_STATUS_TESTID)).toHaveProperty( + "dataset.phase", + "preparing", + ); + expect(screen.getByText(/Step 2 of 3: Uploading agent controller/)).toBeTruthy(); + }); + + it("renders the fallback warning callout when the missing-sandbox notice is present", async () => { + mockPrepareState = { + sessionId: SESSION_ID, + status: "preparing", + steps: [ + { + name: "Reconnecting cloud sandbox", + status: "skipped", + warning: + "Previous sandbox is no longer available — provisioning a fresh one for this branch.", + }, + { name: STEP_CREATE_SANDBOX, status: "running" }, + ], + }; + + render(); + fireEvent.pointerEnter(screen.getByTestId(SETTINGS_BUTTON_TESTID)); + + const status = await screen.findByTestId(PREPARE_STATUS_TESTID); + expect(status.dataset.phase).toBe("preparing_fallback"); + expect(screen.getByTestId("executor-prepare-fallback-warning")).toBeTruthy(); + }); + + it("renders the Resuming session row when session is STARTING with no prepare events", async () => { + mockSessionState = "STARTING"; + + render(); + + expect(screen.getByTestId("executor-settings-button-spinner")).toBeTruthy(); + fireEvent.pointerEnter(screen.getByTestId(SETTINGS_BUTTON_TESTID)); + + const status = await screen.findByTestId(PREPARE_STATUS_TESTID); + expect(status.dataset.phase).toBe("resuming"); + expect(screen.getByText("Resuming session")).toBeTruthy(); + expect(screen.getByText(/Reconnecting to the existing environment/)).toBeTruthy(); + }); + + it("hides the prepare-status section once preparation completes", async () => { + // The READY badge next to the executor name conveys ready-state; the + // dedicated "Environment ready · 12s" row is redundant noise. + mockPrepareState = { + sessionId: SESSION_ID, + status: "completed", + steps: [{ name: "Creating cloud sandbox", status: "completed" }], + durationMs: 12500, + }; + + render(); + fireEvent.pointerEnter(screen.getByTestId(SETTINGS_BUTTON_TESTID)); + + expect(screen.queryByTestId(PREPARE_STATUS_TESTID)).toBeNull(); + expect(screen.queryByText(/Environment ready/)).toBeNull(); + }); +}); diff --git a/apps/web/components/task/executor-settings-button.tsx b/apps/web/components/task/executor-settings-button.tsx new file mode 100644 index 0000000000..33344a870b --- /dev/null +++ b/apps/web/components/task/executor-settings-button.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { IconBox, IconLoader2, IconTrash } from "@tabler/icons-react"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card"; +import { Button } from "@kandev/ui/button"; + +import { getExecutorStatusIcon } from "@/lib/executor-icons"; +import { TaskResetEnvConfirmDialog } from "./task-reset-env-confirm-dialog"; +import type { StatusTone } from "./executor-environment-status"; +import { usePrepareSummary } from "@/hooks/domains/session/use-prepare-summary"; +import { useTaskEnvironment } from "@/hooks/domains/session/use-task-environment"; +import { isPreparingPhase } from "@/lib/prepare/summarize"; +import { PrepareStatusSection } from "./executor-prepare-status"; +import { EnvironmentInfo } from "./executor-environment-info"; + +type ExecutorSettingsButtonProps = { + taskId?: string | null; + sessionId?: string | null; + disabled?: boolean; +}; + +export function ExecutorSettingsButton({ + taskId, + sessionId, + disabled, +}: ExecutorSettingsButtonProps) { + const [open, setOpen] = useState(false); + const [resetDialogOpen, setResetDialogOpen] = useState(false); + const prepare = usePrepareSummary(sessionId ?? null); + const isPreparing = isPreparingPhase(prepare.phase); + // Promote the foreground polling cadence while preparing so the icon flips + // to "ready" without the user hovering over the trigger. + const { env, container, loading, isResetting, reset, status } = useTaskEnvironment( + taskId, + open || isPreparing, + ); + + const handleReset = useCallback( + async (opts: { pushBranch: boolean }) => { + const ok = await reset(opts); + if (ok) { + setResetDialogOpen(false); + setOpen(false); + } + }, + [reset], + ); + + if (!taskId) return null; + + const executorType = env?.executor_type ?? null; + const ariaLabel = computeAriaLabel(isPreparing, status); + + return ( + <> + + + {/* Real + + + + +
+ +
+
+
+ + + + ); +} + +function computeAriaLabel( + preparing: boolean, + status: { label: string; tone: StatusTone } | null, +): string { + if (preparing) return "Executor settings, preparing environment"; + if (status) return `Executor settings, environment ${status.label}`; + return "Executor settings"; +} + +function ExecutorButtonIcon({ + executorType, + preparing, +}: { + executorType: string | null; + preparing: boolean; +}) { + if (preparing) { + return ( + + ); + } + if (!executorType) { + return ; + } + const { Icon, testId } = getExecutorStatusIcon(executorType, false); + return ; +} + +const DOT_CLASSES: Record = { + running: "bg-green-500", + stopped: "bg-zinc-500", + warn: "bg-amber-500", + error: "bg-red-500", + neutral: "bg-muted-foreground", +}; + +function ExecutorStatusDot({ + status, + loading, +}: { + status: { label: string; tone: StatusTone } | null; + loading: boolean; +}) { + const tone = status?.tone ?? "neutral"; + const label = status?.label ?? "not created"; + return ( +