Commit Graph

341 Commits

Author SHA1 Message Date
Luan Martins a852be3c33 feat(web): show task linkage on GitHub PR list (#952)
* feat(web): show task linkage on GitHub PR list

Add a compact indicator next to each PR showing whether a task already
exists. When tasks are linked, show a clickable button with the task
title and a count badge for multiple tasks. When no task exists, show
'No task created yet'.

- Add usePRKeyToTasks hook that maps PR keys to task associations
- Add PRRowTaskIndicator component for the per-PR task UI
- Wire the data through github-page-client into PRList
- Add tests for the indicator and hook utility

* feat(web): improve PR task indicator with task titles and multi-task dropdown

- Show actual task title instead of PR title in single-task case
- Replace count badge with dropdown menu for multiple linked tasks
- Switch icon from GitPullRequest to Checklist
- Load all workflow snapshots on GitHub page to hydrate task titles
- Fix test to use unique task_id per fixture

* feat(web): neutralize PR task indicator color and add tooltip

The checklist icon previously mirrored PR aggregate status, which duplicated
information already shown on the PR row and misleadingly suggested it
reflected task state. Drop the color and add a tooltip surfacing the actual
task title and workflow step.

Adds e2e coverage for the GitHub PR list task indicator: empty badge,
single-task button, and tooltip content.

* fix(web): navigate to task page when clicking PR task indicator

replaceTaskUrl only mutates history.state, which sets active task in the
store but does not trigger a Next.js route change. From /github, this left
the URL pointing to /t/<id> while the page still rendered the GitHub list.
Use router.push so the click actually navigates to the task.

* test(web): cover PR task indicator click navigation

Extends the GitHub PR list task indicator spec with a click assertion
verifying the URL changes to /t/<taskId> and the PR list unmounts.
Regression guard for the replaceTaskUrl → router.push fix.

* fix(github): link task to PR at creation time, not via branch poller

Tasks started from the GitHub PR list "+ Task" button were left without a
github_task_prs row when the worktree branch did not match the PR's head
branch on GitHub. This happened for PR-review-style tasks, which check out
synthetic branches (e.g. feature/review-...-h92) that don't exist as PR
heads. The poller's FindPRByBranch could never resolve them, so only the
first task whose branch happened to match got linked — every other task
created from the same PR was missing from the indicator.

The PR is already known at "+ Task" time, so persist the linkage
immediately rather than waiting on branch-based discovery:

- AssociateExistingPRByURL: new service method that creates the
  github_task_prs row from a PR URL without creating a PR watch. The
  watch is still created later at agent start via ensureSessionPRWatch.
- POST /api/v1/github/task-prs/associate: HTTP endpoint exposing it.
- QuickTaskLauncher.handleSuccess: after task creation, fires a
  best-effort associate call with the task's repository_id and PR URL.
  Failures fall back to the legacy poller path silently.

Backend test covers two distinct tasks linking to the same PR.
E2e adds a PR with two tasks and asserts the multi-task indicator
("Tasks 2") renders alongside the single-task and empty cases.

* refactor(github): make task-PR create endpoint RESTful

Rename POST /api/v1/github/task-prs/associate to POST /api/v1/github/task-prs.
The action-named "/associate" was RPC-styled; the resource collection is the
right URL for creation alongside the existing GET. Frontend client renamed
associateTaskPR → createTaskPR to match.

* fix(github): address PR review findings

- Map invalid PR URL to HTTP 400 (was 500). New ErrInvalidPRURL sentinel
  lets the controller distinguish parse failures from genuine server
  errors. Other failure paths still return 500.
- Prefer dialog-time repositoryId in pickRepositoryIdForPR. Multi-repo
  tasks with shared branch names were ambiguous under pure branch
  matching; the matched repo id captured by the launcher dialog wins
  when present.
- Cover usePRKeyToTasks (not only the prKey util) with hook tests:
  empty input, multi-task grouping, distinct keys, and the non-array
  defensive branch.
- Lock createTaskPR request semantics: method/body now win over a
  caller's options.init so the POST JSON shape can't be overridden.

* fix(web): use TaskPR row id for dropdown key

Multi-repo tasks can produce more than one TaskPR row for the same PR
(one row per repository_id), so keying the multi-task dropdown by
task_id risked a duplicate-key warning and unstable reconciliation.
Switch to the row id, which is unique per row.
2026-05-20 22:38:44 +01:00
José Almeida f2479aabce feat(web): collapsible queue chip with animated panel (#957) 2026-05-20 01:22:25 +01:00
Carlos Florêncio 862822dab1 feat(web): add dev debug logging for git-status, dockview, and chat messages (#961) 2026-05-19 23:41:02 +01:00
Carlos Florêncio 2d219f1d1b fix(web): restore last-selected session tab on task re-entry (#951) 2026-05-19 21:44:15 +01:00
José Almeida 54ddb0aa1e feat(web): highlight active-tab file row in Changes panel (#954)
* feat(web): track active file path for diff panels

Extend resolveFilePath so activeFilePath updates when a file-diff panel
(preview:file-diff or diff:file:<path>) becomes the active dockview
panel. Also re-subscribe onDidParametersChange for the preview file-diff
slot so swapping the preview to a different file refreshes the path.

* feat(web): highlight active-tab file row in Changes panel

When a file's diff tab is the active dockview panel, mark the matching
row in unstaged, staged, and PR Changes lists with data-active="true"
and reuse the existing bg-accent/60 highlight (same as multi-select).
PRFileRow also gains data-changes-file so it shares the existing row
selector with FileRow.

* test(web): cover active-tab highlight unit + E2E

Unit test in FileRow guards the data-active attribute and bg-accent/60
class when isActive (with and without isSelected). New E2E spec drives
the full flow: open file-a diff, switch to file-b, close the preview,
and verify highlight follows when a file moves from unstaged to staged.

* fix(web): use distinct e2e task titles for active-tab highlight spec

clickTab("Changes") fails strict-mode when the task title also contains
the word "Changes" — Playwright matches both the session tab and the
dockview Changes tab. Renames to "Active Highlight (Staged) Test".

* test(web): close pinned diff:file panels in closeFileDiffPreview

The helper previously closed only the preview slot, leaving pinned
diff:file:<path> panels (created when a preview is promoted) active.
Sweeps every diff:file: panel so the row settles to data-active=false
regardless of how the diff was opened.

* test(web): unit-cover resolveFilePath panel-id branches

Exercises every branch of the dockview-store resolveFilePath closure:
file:, diff:file:, preview:file-editor, and preview:file-diff (the
last two via params.path), plus the null-out paths. Catches regressions
in the panel-id mapping without paying for an E2E round-trip.

* fix(web): strip "changes" from active-tab spec profile names

The agent profile name becomes the dockview session tab title, so a
profile named "changes-active-highlight" makes clickTab("Changes")
match two .dv-default-tab elements (session tab + Changes tab) and
trip strict-mode. Rename to "active-highlight" / "active-highlight-
staged" to keep the locator unambiguous.

* docs(web): note multi-repo path collision in PRFilesRepoGroup

Mirrors the FileListBody comment so future authors see the same caveat
in both file-list components: activeFilePath carries the path but not
the repo, so identical paths across repos light up both rows.

* fix(web): snapshot panels before closing in closeFileDiffPreview

dockview's api.panels is a live reference; calling panel.api.close()
removes the panel in place, so iterating the live array skipped every
other diff:file:* panel when two or more were open. Filter into a
local array before closing.
2026-05-19 15:53:48 +01:00
Carlos Florêncio 20a660fe3e fix(web): strip phantom session panels on env-layout restore (#944) 2026-05-19 09:19:05 +01:00
José Almeida b4ce4d6eef refactor(web): improve compact desktop layouts (#937)
* refactor(web): improve compact desktop layouts

* fix(web): address compact desktop review feedback

* fix(web): address responsive review followups

* fix(web): preserve compact sidebar restore sizing

* fix(web): harden compact preset sidebar test

* refactor(web): extract dockview layout hooks
2026-05-18 23:08:14 +01:00
Carlos Florêncio f1cb1fdb21 chore(deps): bump @pierre/diffs to 1.1.22 with regression coverage (#918) 2026-05-17 22:32:02 +01:00
Carlos Florêncio f6bd83da33 refactor: tighten type system across backend + frontend (#920) 2026-05-17 20:16:15 +01:00
Carlos Florêncio 0b2085688b feat(office): autonomous agent management layer (#914) 2026-05-16 20:07:57 +01:00
Luan Martins 7437194e2e fix: show PR diff when clicking file row that has local changes (#908) 2026-05-16 14:04:09 +01:00
José Almeida f3397b4ad7 feat: docker and sprites executor improvements (#738)
* 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: <step>", 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 <X>`
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 <feature>` 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 ~/.<agent> on the
host into /root/.<agent> 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 <kandev-home>/agent-sessions/<instance_id>/. 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 <span tabIndex role="button"> trigger
  for <Button variant="ghost"> so Enter/Space activation, focus rings on
  Safari, and disabled semantics come for free.
- test(docker): rewrite TestDockerStopInstance_PreservesSessionDirOnPlainStop
  to drive the real DockerExecutor.StopInstance path with ContainerID=""
  (skips the docker daemon, exercises the kandev-side cleanup branch).
  Previous version only iterated shouldRunExecutorCleanup strings — would
  pass even if StopInstance always cleaned up. Now table-driven over
  preserve+destructive reasons with on-disk asserts.
- refactor(orchestrator): drop dead `_ = arg` parameters from persist*
  helpers (persistResumeState, persistModelSwitchState, persistLaunchState).
  Brings signatures from 9→6 args, under the funlen lint cap.
- refactor(orchestrator): collapse byte-identical isContainerizedExecutor
  ≡ isCloneBasedExecutorType predicate into one.
- refactor(lifecycle): drop dead getMetadataBool, FileUploader.RunCommand,
  expandMountSource {home} arm (no production agent ships {home} in
  Mounts; codex_acp_test asserts it), dockerTaskBranch one-line wrapper.

* refactor(scripts): postlude owns worktree-branch checkout

Strip the inline `git checkout -B` block from the Docker and Sprites
default prepare scripts. The kandev-managed postlude
(KandevBranchCheckoutPostlude) is appended unconditionally after every
user prepare script, so keeping the same logic inline made the default
and the postlude two sources of truth that could drift.

* refactor(lifecycle): drop dead instance_port persistence entry

`instance_port` is only used as a zap log field — it's never written to
the executor metadata map, so carrying it across resume in
`persistentMetadataKeys` was a no-op.

* refactor(lifecycle): drop legacy {home} session-dir fallback

ExpandSessionDir kept a `{home}/<dotdir>` fallback for the case where
kandevHomeDir was unresolved. In production, ResolvedHomeDir() always
returns a non-empty path, so the fallback only ever fired in tests
that hadn't been updated to pass an instance ID. Returning the empty
string in that case makes container.go skip the bind mount — the
correct behaviour, since without a kandev home there's no session dir
to mount.

* refactor(orchestrator): collapse env-reuse helpers into reuseExistingEnvironment

The two env-reuse helpers (`applyExistingEnvironment` and
`applyExistingEnvironmentRuntimeMetadata`) were always called together
and shared the same intent: carry the previous TaskEnvironment forward
into the launch request. Merge them into a single
`reuseExistingEnvironment(ctx, req, env)` so the executor-type gate
runs once for both env-level fields and runtime metadata, and callers
have a single entry point.

Document the layered metadata rule (running wins for container_id;
env wins for sprite_name and other stable IDs) inline so the next
reader doesn't have to reverse-engineer it from two functions.

* refactor(web): lift prepare-summary reducer + isFallbackNoticeStep into lib/prepare

Move the popover's prepare-summary reducer (and the
`isFallbackNoticeStep` helper duplicated word-for-word in
`prepare-progress.tsx`) into `apps/web/lib/prepare/summarize.ts`. The
hook moves to `hooks/domains/session/use-prepare-summary.ts` to match
the project's domain-organized hook layout.

The popover's PrepareSummary type and the chat panel's EffectiveStatus
serve different UIs, so the panel keeps its own `deriveStatus` — only
the literal duplicate (`isFallbackNoticeStep`) is shared.

* refactor(web): split executor-settings-button by concern

Slice the 471-line button file into three modules with single
concerns: the popover shell stays in `executor-settings-button.tsx`
(168 lines, just trigger + popover layout + icon/status dot),
`executor-environment-info.tsx` owns the env detail rows + status
badge, and `hooks/domains/session/use-task-environment.ts` owns the
fetch/poll/reset lifecycle alongside other session hooks.

* refactor(e2e): extract docker + session helpers from docker-launch.spec

Pull the inline docker CLI helpers (`dockerInspectExists`, `dockerStop`,
`dockerCurrentBranch`, etc.) and the generic session-state pollers
(`waitForLatestSessionDone`, `waitForSessionDone`,
`waitForSessionEnvironment`) into `e2e/helpers/docker.ts` and
`e2e/helpers/session.ts`. The spec drops from 763 to 647 lines and the
helpers can be reused by future docker / session tests.

* test(scripts): execute postlude under bash -e against a real git repo

Pair the static-content assertion with a behaviour test that runs the
postlude as a real shell script. Three scenarios exercise the three
branches inside the snippet: remote tip exists (track origin), no
remote (fresh local branch), and already on the target branch
(idempotency). This catches regressions where the snippet still parses
as bash but no longer does the right thing — wrong refspec, missing
-B, swallowed exit code, etc.

* test(sprites): integration check for stop preserve + resume reconnect path

Each piece of the resume-fast path is unit-tested separately
(StopInstance keeps the token cache, spritesShouldReconnect reads
sprite_name, the reconnect plan has 3 steps). This new test wires them
together end to end so a future change that accidentally strips
sprite_name on stop or forgets to look at metadata on launch fails
loudly here, not at runtime.

* test(sprites): assert plan ordering instead of absolute indexes

Replace `plan.index(spriteStepWaitHealthy) == 1` with an
`assertPlanOrder` helper that checks "X runs before Y". The contract
the test guards is the order of create-sprite → wait-healthy →
agent-instance, not their absolute slot numbers — adding a new step
shouldn't break the test if the order is preserved.

* test(web): hoist test constants above vi.mock factories

Move SESSION_ID and friends above the vi.mock factories so the mock
closures don't depend on vitest hoisting magic capturing TDZ-undefined
locals. The previous layout worked because factories aren't invoked
until the module under test is imported (after const initialization),
but a refactor that imports earlier would break the closure silently.

* fix(seeder): contain localFileUploader writes inside the session root

CodeQL flagged go/path-injection on the localFileUploader.WriteFile
sink: instanceID flows into the write path, and a malformed value
would let writes escape the kandev session root and clobber arbitrary
host files. Bind the uploader to its session root and reject any path
that filepath.Rel resolves to a "../" escape. Production callers
always pass an internally generated instanceID; the check is defense
in depth against a future caller plumbing in user input.

* refactor(docker): split reconnectToContainer + cover reconnect helpers with unit tests

Address recurring review feedback on the docker reconnect path:

- reconnectToContainer was 98 lines (over the 80-line repo limit). Split
  into ensureContainerRunning (inspect + start + re-inspect) and
  bringupAgentctl (health-check, find-or-create instance, transparent
  re-handshake). The orchestration body now reads top-down at 49 lines.
- resolveDockerEndpoint and waitForAgentctlHealth grew real branches in
  this PR (host-port lookup with container-IP fallback; 240×500ms
  retry loop) but had no direct tests. Introduce narrow hostPortLookup
  / healthChecker interfaces, satisfied by *docker.Client and
  *agentctl.ControlClient, so both functions are now driven by stubs
  that exercise happy-path, fallback, retry-until-healthy, exhaustion,
  and context-cancellation branches.
- Add direct unit tests for the docker package's pure helpers
  (normalizeDockerHostIP, buildDockerPortBindings) and for
  dockerAgentctlPortBindings — a regression in the published port
  range or host IP would silently break container reconnect.

* test(handlers): cover ensureShellExecution stale-execution recovery

The recovery branch (Failed execution → CleanupStaleExecutionBySessionID
→ relaunch) is the load-bearing piece of shell session resilience and
had no direct test. Extract the body into a free function that takes a
narrow shellLifecycle interface so the four branches are testable
without standing up a real lifecycle.Manager:
  - happy path (running execution, no cleanup)
  - stale recovery (Failed → cleanup → fresh)
  - initial GetOrEnsureExecution failure
  - cleanup failure must NOT trigger a re-launch loop
  - relaunch failure surfaces a clear "recover execution" error

* fix(backend): clear lint blockers from CI lint job

CI's only-new-issues lint surfaced three real items left over from
recent additions:

- container.go bootstrap snippet had two `fi` shell tokens flagged by
  dupword. Tagged with the same nolint directive used by the
  KandevBranchCheckoutPostlude (two distinct shell blocks closing).
- DockerExecutor.CreateInstance was 89 lines (over funlen 80). Split
  the body into helpers: reportCreateInstanceProgress (deferred
  progress reporter), tryReconnect (gates the resume path),
  seedSessionDir (per-container auth seed), buildContainerLaunchConfig,
  and buildCreatedInstance. CreateInstance is now a 30-line top-down
  orchestrator.
- executor_execute.go was 887 effective lines (over revive 800). Move
  reuseExistingEnvironment + its helpers (latestExecutorRunningForEnv,
  executorRunningMatchesEnvironment, applyExecutorRunningMetadata,
  ensureLaunchMetadata) into a sibling executor_environment_reuse.go.

* fix(web): only block submit during real env prepare, not every STARTING

The "agent still being set up" submit gate was triggering on every
session that briefly transitions through STARTING — including local
quick-chat, which has no prepare phase and has its agent live within
~100ms. The eager-init quick-chat e2e tests (chat/quick-chat.spec.ts
and chat/message-queue.spec.ts) wait for the editor to become
editable, then press Cmd+Enter. The race made wrappedSubmit a no-op
exactly when the test was firing — message dropped, agent never saw
it, downstream assertions failed.

Split the merged isStarting flag in useSessionState: keep the broad
flag (still used for the workspace-prep loading UI), and expose
isPreparingEnvironment as a separate signal that's only true while
prepareProgress.status === "preparing". Thread it through chat-input-
area → chat-input-container → useChatInputContainer and gate the
submit-disabled tooltip on it instead of the broad isStarting.

Net: Docker / Sprites bootstrap still surfaces the "agent still being
set up" message and disables submit; quick-chat / local sessions stop
silently dropping in-flight messages.

* fix(web): keep editor disabled during STARTING so quick-chat sends don't race

My previous fix made submit no longer block on isStarting, with the
goal of keeping local quick-chat sessions from silently dropping
messages. The trace from CI showed it traded one failure for another:
the editor became contenteditable while the session was still
STARTING, the e2e suite pressed Cmd+Enter immediately, and the
backend rejected with "Failed to send message to agent" because the
agent process wasn't ready yet.

Restore the original semantics — STARTING blocks both editor and
submit — but keep `isPreparingEnvironment` as the narrower trigger for
the "agent still being set up" tooltip. Net behaviour:
  - STARTING transition: editor disabled, no tooltip (signal is the
    disabled state itself)
  - container/sandbox prepare: editor disabled + tooltip explaining why
  - quick-chat sessions: editor stays disabled until RUNNING, no race

* docs(web): correct isPreparingEnvironment JSDoc after STARTING re-block

The 9389ac9 fix restored isStarting as the editor disabled gate.
Update the JSDoc on isPreparingEnvironment to match: it now drives
the "agent still being set up" submit-disabled tooltip only — the
disabled state itself is gated on the broader isStarting so e2e
Cmd+Enter doesn't race the not-yet-ready agent.

* fix(lifecycle): make agentctl health retry delay cancelable

Replace `time.Sleep(retryDelay)` in `waitForHealth` (container.go) and
`waitForAgentctlHealthWith` (executor_docker.go) with a cancelable
`select { <-ctx.Done() | <-time.After(retryDelay) }`. Also skip the
final retry's sleep — the loop only re-enters when `i+1 < maxRetries`,
so the extra delay was just added latency on aborted launches.

Cancellation now propagates within ≤500 ms instead of waiting for the
next probe iteration to observe `ctx.Err()`.

* fix(scripts): non-destructive postlude branch checkout

The previous postlude tried `git fetch + git checkout -B feature
origin/feature` first, which is force-reset semantics: any unpushed
local commits on the resumed workspace would be orphaned without
warning.

Reorder to prefer the existing local branch (preserving in-flight
work after a sandbox/container resume), fall through to a fresh
tracking branch off origin, and only as a last resort create the
branch off HEAD:

  1. local feature branch exists  → checkout (preserves)
  2. else fetch + checkout -b      → tracks origin, no force-reset
  3. else                          → create new local branch

Behaviour tests still pass: fresh sandbox with remote tip → falls
through to step 2 (clean track). The static-content test now also
verifies the destructive `-B "feature" "origin/feature"` form is
absent.

* fix(lifecycle): reject non-regular files for KANDEV_MOCK_AGENT_LINUX_BINARY

`os.Stat` succeeds on a directory, so the previous resolver happily
accepted a directory path and forwarded it to the Docker bind-mount +
exec path, where it surfaced as a confusing "permission denied" /
"exec format error" deep in container bootstrap.

Validate `info.Mode().IsRegular()` up front and return a clear error
naming the env var.

* docs(test): update stale comment after retry-delay became cancelable

aaec9e87 swapped the retry sleep for a select on ctx.Done. The test
comment in TestWaitForAgentctlHealth_HonoursContextCancellation still
described the old "subsequent time.Sleep is short" reasoning, which is
no longer how cancellation propagates — the select exits immediately
via ctx.Done() within the same iteration.

* fix(web): extract buildDialog{FormBody,Footer}Props to satisfy lint cap

After the main merge, prettier expanded my hand-compacted destructuring
in TaskCreateDialog onto multiple lines, putting the function at 115
lines (over the 100 cap). Extract the two prop-assembly blocks into
buildDialogFormBodyProps + buildDialogFooterProps helper functions so
the orchestrator becomes a thin JSX wrapper. No behaviour change — the
helpers project the same shape, 1:1.

Also fixes the upstream CI failure: prettier --check was rejecting the
hand-compacted destructuring lines.

* test(executor): cover repositoryCloneURL LocalPath fallback

The function gained a second code path to support Docker E2E tests with
file:// remotes — when ProviderOwner/ProviderName are both empty and
LocalPath is set, it shells out to git remote get-url origin. The
original table-driven test had eight cases, none of which exercised
the new branch.

Add a dedicated TestRepositoryCloneURL_LocalPathFallback with four
sub-cases:
  - local-only repo with a valid origin remote → returns the URL
  - local-only repo without an origin remote → returns empty
  - local-only non-git directory → returns empty
  - no provider and no local path → returns empty (regression for the
    early-return branch the old table already covered)

* refactor(web): move task-create-dialog prop builders to sibling file

3a0c45a0 introduced buildDialogFormBodyProps + buildDialogFooterProps
to slim TaskCreateDialog under the function-line cap, but adding them
in-file pushed task-create-dialog.tsx to 612 raw lines. Local eslint
still passes (skipBlankLines + skipComments brings the effective count
under 600), but Claude's review flagged the raw-line growth as a
blocker — and the prop builders are pure projections from setup state
to plain JS objects, no JSX, so they belong outside the component
module anyway.

Move computeHasAllBranches and both builders to a new
task-create-dialog-prop-builders.ts. Export TaskCreateDialogProps and
useTaskCreateDialogSetup from dialog.tsx so the builders can derive
their parameter types via ReturnType<typeof useTaskCreateDialogSetup>.

task-create-dialog.tsx: 612 → 522 raw lines.

* chore(web): apply prettier to task-create-dialog-prop-builders.ts

The new file from 08aa04c5 wasn't run through prettier before push,
so CI's --check rejected the long import line. Mechanical reformat
(import collapsed to one line, no semantic change).

* fix(web): import-type the prop-builders circular dep + add computeHasAllBranches test

Two findings from Claude on 08aa04c5 + fa5ba9d6:

  - The mutual import between task-create-dialog-prop-builders.ts and
    task-create-dialog.tsx is purely type-level (TaskCreateDialogProps
    is an interface; useTaskCreateDialogSetup is consumed only through
    `ReturnType<typeof ...>`). Switch to `import type` so bundlers and
    analysis tools don't flag this as a runtime cycle.

  - computeHasAllBranches was a private function in
    task-create-dialog.tsx and didn't need its own test. Moving it to
    a sibling .ts utility makes it a public helper, and the SKILL.md
    convention is that public utilities carry their own tests. Add a
    table-driven unit test covering the three branches: noRepository
    short-circuit, useGitHubUrl branch validation, and the
    every-repo-has-a-branch case.

* fix(web): pass executorType to kanban card RemoteCloudTooltip

The kanban-card call site was missing the optional executorType prop,
so getExecutorStatusIcon(undefined, ...) fell through to the default
arm and rendered IconServer for every remote-executor task —
including Docker, which should be IconBox. Other call sites
(task-item.tsx, session-mobile-top-bar.tsx) already pass it.

This was a pre-existing bug from c64e8356 that became visible because
this PR makes Docker tasks first-class on the home view.

* fix(web): give executor popover env header breathing room above the title

The EnvironmentInfo block was `px-3 pb-1.5` (no top padding), so the
"Local Docker" heading hugged the popover top edge when no prepare-
status row was rendered. Add `pt-2.5` to match the visual rhythm of
the prepare-status row that sometimes sits above it.

* fix(sprites): DestroySandbox passes terminal StopReason so cleanup actually runs

shouldRunExecutorCleanup gates Sprites teardown on a small set of
terminal reasons (task/session archived/deleted). DestroySandbox was
calling StopInstance with an empty StopReason, which never matched
that set — so the sandbox was never actually destroyed, only the
local execution state was cleaned up. The sprite kept running
upstream until it idled out.

Pass StopReasonTaskDeleted explicitly. While here, lift the four
terminal-reason literals into named constants so callers don't have
to keep them in sync with shouldRunExecutorCleanup's switch.

* fix(web): block submit when selected agent is incompatible with executor

useExecutorProfileCompat returned noCompatibleAgent=true only when
NO agent profile was compatible with the chosen executor. The user
could still pick an agent that happened to be incompatible with the
currently-selected executor (e.g. by switching executor after the
agent was chosen) and the submit gate didn't fire.

Two fixes:
  - Hook now takes the selected agent profile ID and returns
    noCompatibleAgent=true when the *selected* agent isn't in the
    compatible set (covers both the old "no agents at all" case and
    the new "wrong agent picked" case).
  - Footer's session-mode disable was gating only on missing agent;
    add noCompatibleAgent so the disable matches the reason text
    sessionDefaultReason already surfaces.

* fix(task-env): sanitize generic 500 response on reset failure

The reset-environment endpoint was returning the raw err.Error() in the
500 response body, leaking internal details (paths, wrapped chains,
infra error strings). Keep the structured log at error level for
operators and return a generic "failed to reset task environment"
to clients.

* fix(worktree): push to the branch's configured upstream remote, not hard-coded origin

PushEnvironmentBranch was running `git push origin <branch>`, which
breaks for repos whose primary remote isn't called "origin" (fork
workflows that use "upstream", "github", etc.). Read the branch's
configured upstream remote via `git config branch.<branch>.remote`
and fall back to "origin" only when no upstream is set.

* refactor(web): type workflows/snapshots in DialogFormBodyProps properly

DialogFormBodyProps had `workflows: unknown[]` and `snapshots: unknown`
which dropped type safety and forced unsafe casts at the call site.
Replace with the actual store-derived types (WorkflowsState["items"]
and KanbanMultiState["snapshots"]) that the call sites already
produce.

* fix(turns): align info.TaskEnvironmentID with the resolved env after fallback

GetWorkspaceInfoForSession initializes info.TaskEnvironmentID from
session.TaskEnvironmentID, then resolves the env — first by that ID,
falling back to GetTaskEnvironmentByTaskID when the session's ID
points to a stale/missing row. When the fallback kicks in, the
resolved env has a different ID, but applyTaskEnvironmentToWorkspaceInfo
was only updating info.TaskEnvironmentID when it was empty. So
downstream reconcilers received metadata + path from one env keyed by
a different env's ID.

Always overwrite info.TaskEnvironmentID with env.ID inside the apply
function — once we've picked an env to spread, the info should
identify that env consistently.

* fix(web): gate compatibility on effectiveAgentProfileId, not the raw form value

The previous fix passed fs.agentProfileId to useExecutorProfileCompat,
so workflow-locked sessions (where fs.agentProfileId is empty and the
real value comes from workflowAgentProfileId) bypassed the
noCompatibleAgent check entirely — selectedAgentProfileId was "" so
the new "selected agent isn't in compatible set" branch silently
returned false.

Thread `effectiveAgentProfileId` (which already coalesces the form
value with the workflow override) through to the compat hook so
locked agent selections are gated the same way as user-picked ones.

* test: cover detectBranchRemote + applyTaskEnvironmentToWorkspaceInfo stale-ID fallback

Two test additions for the Round 29/30 Claude blockers — both are
recent backend fixes (a07b8f1f, e6195f3f) that landed without
regression coverage.

  - worktree_test.go: TestDetectBranchRemote covers the three
    branches of the new helper — configured upstream returned,
    no upstream → "origin" fallback, non-git dir → "origin"
    fallback. GIT_CONFIG_NOSYSTEM=1 keeps host config from
    interfering.
  - service_turns_test.go: a regression test that drives
    GetWorkspaceInfoForSession down the fallback path
    (session.TaskEnvironmentID points at a missing row; real env
    exists under a different ID for the same task) and asserts
    info.TaskEnvironmentID is aligned with the resolved env, plus
    WorkspacePath/container_id come from the same env.

* fix(backend): extract defaultGitRemote constant to satisfy goconst

The new detectBranchRemote helper plus its tests pushed "origin"
literal occurrences past goconst's min-occurrences=3 threshold.

* fix(backend): inject git identity in worktree tests for CI

CI runners have no user.email/user.name configured, so
`git commit --allow-empty` aborts with "Author identity unknown".
GIT_AUTHOR_*/GIT_COMMITTER_* env vars override the config check.

* fix(backend): base mustGit env on os.Environ to keep HOME/PATH

Appending to a nil cmd.Env produced a single-element slice that
replaced the inherited environment, leaving the git subprocess
without HOME, PATH, etc. Base on os.Environ() instead.
2026-05-14 21:26:09 +01:00
Luan Martins 1359811187 feat(mobile): replace merged-diff view with timeline + overlay sheets on Changes tab (#902) 2026-05-14 10:27:38 +01:00
Carlos Florêncio 39406fa778 fix(web): anchor manual PR panel open to the session's live group (#901) 2026-05-13 23:46:36 +01:00
Carlos Florêncio 8d593bcddb fix(web): scope pending-permission scan to current turn (#899) 2026-05-13 20:57:03 +01:00
Carlos Florêncio 7011bbcbc4 fix: session tab leak, PR icon crash, and PR review prompt scope (#897) 2026-05-13 18:39:01 +01:00
Salim Belkhir 5b23478323 feat(web): differentiate pending permission icon from turn finished in sidebar (#882) 2026-05-13 18:04:37 +01:00
Carlos Florêncio 8fee2de145 fix(web): preserve sidebar scroll position across task switches (#884) 2026-05-12 18:54:21 +01:00
João Salavisa 45a46f9899 feat: support tasks without a repository (#850)
* feat: support tasks without a repository

Lets users create a task with no repository attached — useful for research,
brainstorming, design discussions, or pointing the agent at an existing
non-git folder. The create-task dialog gains a 3-mode segmented control
(Repo / URL / None); "None" mode replaces the chip row with a folder
picker that browses the host filesystem via a new GET /api/v1/fs/list-dir
endpoint and either commits an absolute path or falls back to a kandev-
managed scratch workspace at <homeDir>/tasks/<workspaceID>/<taskID>/.

The worktree executor is hidden from the picker in no-repo mode (it
inherently needs a repo); the lifecycle manager also short-circuits
worktree mode server-side as a belt-and-suspenders guard. The Changes
panel auto-closes for repo-less tasks since it has nothing to show, and
the cumulative-diff handler returns null instead of erroring when no
base commit exists.

Spec + plan added at docs/specs/tasks-without-repositories/.

* fix(backend): anchor folder picker to discovery roots (CodeQL path traversal)

CodeQL flagged the new GET /api/v1/fs/list-dir handler for piping a
user-controlled path straight into os.Stat / os.ReadDir. Restrict the
listing to paths inside the configured discovery roots (defaults to
$HOME, same allowlist as repository discovery), reject anything outside
with ErrPathNotAllowed → HTTP 403, and clip the breadcrumb's "up"
button at the root so the picker can't be used to walk above the
allowed prefix.

Tests cover: rejection of paths outside roots, traversal escapes (../),
default-to-first-root, and parent exposure for nested paths.

* fix(backend): use os.Root for folder picker (recognized CodeQL sanitizer)

Previous attempt anchored to discovery roots via isPathAllowed, but
CodeQL's data flow doesn't model that helper as a sanitizer and kept
flagging os.Stat / os.ReadDir. Switch to os.Root (Go 1.24+): open the
matching discovery root once and run Stat/Open/ReadDir against the
relative path within it. The OS itself enforces containment — symlinks
and traversal cannot escape — and CodeQL recognizes os.Root as safe.

* fix: address PR review feedback

- Changes panel auto-close now distinguishes "task loading" from
  "task has no repos" (returns boolean | null), so the panel isn't
  permanently removed during the first-render window before task
  data lands in the store.
- Reset noRepository / workspacePath in resetDiscoveryState so
  reopening the dialog after a None-mode task doesn't carry the
  flag forward.
- Folder-picker breadcrumb: dropped dead `i > 0 && i > 1` condition
  (always equivalent to `i > 1`); chevrons now render between every
  segment as expected.
- list-dir HTTP handler returns a generic error message instead of
  forwarding raw OS errors that leak host paths.
- Document the dataDir → kandevHome inversion convention next to
  the scratch-path computation so future readers don't mistake the
  filepath.Dir call for a fragile derivation.

* fix: address CodeRabbit review nitpicks

- Trim whitespace on workspace_path before persisting to task metadata
  so a value of "   " doesn't get stored as a non-empty path.
- Extract the workspace_path metadata key into models.MetaKeyWorkspacePath
  alongside the existing MetaKeyAgentProfileID / MetaKeyExecutorProfileID
  so the set/read sites can't drift apart.
- Folder picker: reset cached listing when the popover closes, so
  reopening reloads from `value` instead of showing the last browsed
  directory the user navigated away from without committing.
- Submit payload: send `workspace_path: undefined` instead of an empty
  string when the trimmed value is empty, matching the noRepository=false
  case and keeping the API contract clear.

* fix: scratch path lands in kandev home, not its parent

Claude caught a real bug — `m.dataDir` is set from `cfg.ResolvedHomeDir()`
in cmd/kandev/agents.go, not `ResolvedDataDir()` (the field name is
misleading and the comment was wrong). My previous use of
`filepath.Dir(m.dataDir)` therefore walked one level above the kandev
home, so scratch dirs landed at e.g. `~/tasks/<ws>/<task>` instead of
`~/.kandev/tasks/<ws>/<task>` — and at the project root in dev mode
where `KANDEV_HOME_DIR=<project>/.kandev-dev`, which is what the user
saw on their machine.

Use `m.dataDir` directly (it's already the kandev home), document the
field's misnamed-but-correct semantics, and tighten the test fixtures.

Also:
- folder-picker trigger gets `cursor-pointer` (CodeRabbit nitpick).

* fix: pass workspacePath through handleCreateWithoutAgent too

The "create without agent" path called buildCreateTaskPayload without
the new workspacePath field, so picking a folder + clicking "Create
without starting" silently dropped the path. Now matches the
performCreate (with-agent) path.

* fix: satisfy DialogFormState in new-subtask form stub

PR #852 (merged after this branch was created) introduced
new-subtask-form-state.ts which stubs out a DialogFormState for the
subtask dialog. Adding noRepository / setNoRepository / workspacePath /
setWorkspacePath to the type made that stub incomplete, breaking the
Next.js production build (caught by CI, missed by tsc --noEmit).

Add the four inert fields to the stub, alongside the existing fresh-
branch inert fields, and pull both groups into named constants to
keep the useMemo body under the per-function line cap.

* fix: keep toggle-github-url testid as alias on URL-mode button

The new source-mode segmented control replaced the previous
"or paste a GitHub URL" text link, which had data-testid="toggle-github-url".
The existing e2e suite (create-task-github-url.spec.ts and the new
subtask.spec.ts from #852) still selects the URL toggle by that testid.
Keep "toggle-github-url" as the testid on the URL-mode button so both
suites pass without a sweep across all e2e files. Click semantics are
unchanged — single click enters URL mode.

* test(e2e): update toggle assertion for new source-mode segmented control

create-task-github-url.spec.ts:892 was checking the old text-link
toggle's two-way behavior with text labels ("or paste a GitHub URL" /
"use a workspace repository"). The new segmented control has explicit
mode buttons; assert aria-checked instead of label text. The other 9
clicks on `toggle-github-url` in this file still work via the legacy
testid alias on the URL-mode button.

* refactor: extract useActiveTaskHasRepos to its own hook file

dockview-desktop-layout.tsx grew to 609 lines with the new hook +
the Changes panel guard, over the 600-line cap documented in
CLAUDE.md (lint passed but the convention is real). Move the hook
to hooks/domains/kanban/use-active-task-has-repos.ts. File drops
to 590 lines.

* chore: gitignore .claude/scheduled_tasks.lock runtime file

* fix(web): lock chip-row min-height so modal doesn't shift on mode toggle

The chip row uses flex-wrap; Repo mode (chip + branch + add button +
segmented control) sometimes occupies a different vertical extent than
None mode (single folder picker + segmented control), causing the
create-task modal to jump a few pixels when toggling between sources.
Set min-h-9 so the row always reserves at least one chip-row of
height regardless of the active mode.

* feat: let folder picker browse anywhere, drop allowed-roots gate

The picker is for choosing a starting folder for a repo-less task —
users legitimately want /tmp, /var/log/foo, or any directory they have
read access to. Coupling it to the repo-discover endpoint's
discoveryRoots ($HOME by default) was too tight: paths like /tmp
returned 403 "path is not within allowed roots".

Decouple ListDirectory from discoveryRoots. The repo-discover endpoint
(DiscoverLocalRepositories, ValidateLocalRepositoryPath,
LocalRepositoryStatus, ListBranches) stays anchored to the allowlist
since picking a repo to import is a workspace-scoped operation; the
folder picker trusts the local-process boundary instead.

CodeQL is kept happy by routing the FS operations through os.Root
opened at "/" — the Go stdlib sanitizer for path injection still
recognises it (containment to / is trivially satisfied for every
absolute path on the host).

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-12 01:10:22 +01:00
José Almeida 0743477d48 feat: persist N-entry FIFO message queue (#864)
* feat(orchestrator): persist N-entry FIFO message queue

Replaces the single-slot, latest-wins, in-memory queue with a SQLite-backed
FIFO queue (default cap 10 per session, configurable via
KANDEV_QUEUE_MAX_PER_SESSION). Drain stays one-per-turn so each entry gets its
own user message and `on_turn_*` workflow cycle. Inter-task `message_task_kandev`
returns a structured `queue_full` error with sender attribution when the
target session is saturated, so the calling LLM can decide whether to retry.

Surfaces a new WS action `message.queue.remove` for single-entry deletion;
`message.queue.cancel` now means clear-all. Edit/remove are keyed on the
entry's UUID, returning `entry_not_found` when atomic-take won the drain race.

* feat(web): N-entry queue state, API, and optimistic hook

Replaces the single-slot QueueStatus with an ordered `entries[]` per session
plus capacity meta (`count`, `max`). API exposes typed `QueueFullError` and
`QueueEntryNotFoundError`; the hook surfaces optimistic edit/remove with
drain-race rollback (refetch on `entry_not_found`).

* feat(web): inline ghost-message queue UI with sender chips

Replaces the floating QueuedMessageIndicator overlay with a stack of ghost
messages rendered above the chat input. Each ghost shows a sender chip
(user / agent) and per-entry edit/remove. A small "n/max queued · clear all"
pill summarizes capacity. Inter-task entries are visually distinct and edit
is gated to the original sender.

* fix(queue): address review — auth guard, TOCTOU, error UX

- Add `messagequeue.QueuedByUser` / `QueuedByAgent` constants and centralize
  the `queue_full` error code so the WS and MCP handlers stay in sync.
- WS `message.queue.{add,append,update}` default empty user_id to QueuedByUser
  so the UpdateContent ownership guard always runs against a non-empty owner;
  inter-task entries (`queued_by="agent"`) can no longer be overwritten by
  user-initiated edits even when `user_id` is omitted from the payload.
- memoryRepository: hold the mutex through the whole append-or-insert path
  (prior split released the lock between tail-check and Insert, allowing two
  same-sender callers to race past coalescing). TransferSession now shifts
  source positions past the destination's max so transferred entries always
  drain after pre-existing destination entries, matching SQLite semantics.
- Frontend: surface edit/clear/remove failures via `sonner` toasts (drain race
  → "message already sent"); exit edit mode on failure instead of leaving the
  textarea silently stuck. Hide the remove button on read-only (agent) ghosts.
- `useQueue.clearAll` no longer derives `max` from pre-clear entry count;
  resets to `metaMax ?? 0` so the next status_changed event lands authoritative
  capacity.
- `senderKindOf` drops the unreachable metadata fallback; route on `queued_by`
  alone since `dispatchTaskMessage` always sets it.
- Test loggers now write to stderr per CLAUDE.md "stderr for logs" guideline.

* test(web): align queue e2e selectors with new ghost-message UI

The ghost-message rewrite renamed button titles ("Cancel queued message"
→ "Remove queued message", "Edit message" → "Edit queued message") and
introduced a `data-testid="queued-ghost-list"` container. The
`message-queue.spec.ts` Playwright tests still pointed at the old titles.

* fix(queue): address second review pass — auth, drain race, tests

- WS handlers (`add` / `append` / `update`) now reject `user_id="agent"`
  outright, closing the gap a hostile WS client could exploit by
  impersonating the inter-task sender identity to overwrite agent
  entries (greptile P1).
- WS `update` / `remove` now require `session_id` so the post-mutation
  `MessageQueueStatusChanged` broadcast can never be silently skipped.
- `sqliteRepository.TakeHead` checks `RowsAffected()` after DELETE;
  when two concurrent take callers race past the busy-timeout the
  loser sees 0 affected rows and now returns `nil, nil` instead of
  the already-drained message (would otherwise dispatch the same
  prompt twice).
- `Service.GetStatus` returns `Entries: []` instead of `nil` on the
  error path so the wire shape stays consistent (frontend already
  guards but strict consumers wouldn't tolerate the type mismatch).
- New tests: `queue-api.test.ts` covers `rethrowQueueError` mappings
  (queue_full / entry_not_found / generic / Error pass-through);
  `session-slice.upsert.test.ts` covers `setQueueEntries`,
  `removeQueueEntry`, and `clearQueueStatus`. Backend gains an
  agent-impersonation handler test.
- `rethrowQueueError` now surfaces `WSError.message` for non-queue
  errors instead of stringifying the whole object.
- Cleanup: replace unused `a, c := ...` discards in service_test
  with `_, _ = ...`.

* fix(queue): scope update/remove by session_id (cross-session deletion guard)

The MCP `queue_full` error payload deliberately discloses sibling-task
entry IDs (so the calling agent's LLM can reason about the wedge state).
Without a session_id filter on `DeleteByID` / `UpdateContent`, a hostile
agent that received that payload could call `wsRemoveEntry` /
`wsUpdateMessage` against another task's entries and delete or rewrite
them — only the per-entry UUID was being checked.

- `Repository.UpdateContent` and `Repository.DeleteByID` now require
  `sessionID` and add `AND session_id = ?` to the SQL guard. The memory
  repo mirrors the scope (lookup keyed on session before the entry scan).
- `Service.UpdateMessage` / `Service.RemoveEntry` take `sessionID`; the
  WS handlers thread `req.SessionID` through (already validated as
  non-empty in the previous fixup).
- TypeScript: `session_id` is now required on `updateQueuedMessage` /
  `removeQueuedEntry` params so callers fail at compile time, matching
  the backend contract.
- Tests cover the cross-session attack: `RemoveEntry`/`UpdateMessage`
  with an attacker-supplied session_id but a real entry id from another
  session must return `ErrEntryNotFound` and leave the victim entry
  untouched. SQLite repo gets parallel coverage.

* fix(queue): propagate transfer error + tighten WSError narrowing

- `Service.TransferSession` now returns an error so callers can fail
  closed instead of silently leaving entries orphaned on the previous
  session. The two existing call sites pick the right policy:
  `switchSessionForStep` (reusing an existing session) propagates the
  error so the workflow switch aborts; the new-session path logs and
  continues, since the new session is already committed and rolling
  back here would leave a half-switched workflow.
- `asWSError` now also narrows on `message` / `details`, so WS payloads
  without a `code` field are still mapped to a meaningful `Error.message`
  via `rethrowQueueError` (instead of "[object Object]"). Native
  `Error` instances explicitly skip the WS-error branch so they pass
  through untouched, preserving the existing pass-through guarantee.
- `queue-api.test.ts`: `expect.assertions(N)` on the catch-only tests
  so a missing throw fails the test instead of silently passing.

* fix(queue): check TransferSession error in service tests

The TransferSession signature change in 83abe6d7 broke errcheck on two
test sites that hadn't been updated. Wrap them in require.NoError.
2026-05-11 22:40:36 +01:00
Carlos Florêncio f97ac8bbc1 feat: streaming agent install, PTY login terminal, docker container UX (#869) 2026-05-11 17:52:05 +01:00
José Almeida 14c19ea538 feat(web): add CI hover popover on PR top-bar button (#846)
* feat(github): plumb CI popover fields + branch protection cache

Adds RequiredReviews/UnresolvedReviewThreads/ChecksTotal/ChecksPassing
to TaskPR + PRStatus. Extends the GraphQL PR query with reviewThreads
to populate UnresolvedReviewThreads on every poll. New branch_protection.go
holds an in-memory TTL cache + BranchProtectionFetcher interface;
PATClient/GHClient implement REST/CLI lookups so SyncTaskPR can resolve
required_approving_review_count for the PR's base branch. SyncTaskPR
preserves existing counts when a lightweight (GraphQL) sync returns 0
so the popover doesn't flap during the polling cycle.

* feat(github): mock controller hooks for CI popover e2e

Extends associateTaskPR to accept review/check counts, required_reviews,
and unresolved_review_threads, and switches to ReplaceTaskPR so re-POSTing
the same task drives the popover live-refresh path. Adds POST /pr-feedback
to seed PRFeedback (checks/reviews/comments) keyed by a deterministic
synthetic head_sha, and PUT /auth-health to flip the mock client's
authenticated flag for the "Reconnect GitHub" branch. Mock client gains
ReplaceCheckRuns/ReplaceReviews/ReplaceComments helpers and an
authenticated field that defaults to true.

* feat(web): CI hover popover on PR top-bar button

Hovering the PR top-bar button now reveals a popover with
Passed/In-Progress/Failed counts, a workflow-rollup list under the
non-empty buckets (with "open on GitHub" + "+ ctx" affordances on
failed rows), a review-state row ("Approved 1 / 2 required"), and
unresolved-comments count. Click on the button keeps the existing
"open PR detail panel" behavior so shipped tests stay green.

PRFeedback is lazy-fetched on hover-open via a new SWR-style hook
backed by a 20-entry LRU cache slice; the popover refetches whenever
the underlying TaskPR's updated_at changes (the WS handler already
applies poll deltas). Bucket logic + workflow grouping live in a
small util with vitest coverage. The aggregate-popover variant for
multi-PR tasks ships but the multi-PR trigger preserves its existing
DropdownMenu so per-row click → addPRPanel still works.

* test(e2e): pr-topbar-popover scenarios A–F covering CI popover

11 specs covering: count rows + zero-bucket hiding, workflow rollup
with (N/M passed) badge under Failed, review row + unresolved comments,
hover open + leave close, failed-row open/+ctx affordances, header
external link, "No checks have started" empty state, "Reconnect GitHub"
auth-lost branch, and live count updates on a re-associate.

Adds api-client helpers (mockGitHubSeedPRFeedback, mockGitHubSetAuthHealth,
extended mockGitHubAssociateTaskPR) and SessionPage popover accessors.

* fix(github): address bot review comments on CI popover

- branch_protection.go: evict stale entries on read so the cache map
  cannot grow unbounded over a long-running deployment; rewrite the
  comment to drop the inaccurate singleflight claim (no dedup logic
  exists, concurrent misses race harmlessly to set()).
- gh_client.go: replace fragile strings.Contains "HTTP 404" with an
  isNotFoundErr helper that tolerates the formats current and future
  gh CLI versions use ("HTTP 404", "404 Not Found", "status: 404").
- graphql.go: extend reviewThreads with totalCount and attribute the
  un-fetched tail to the unresolved bucket so PRs with >100 threads
  don't silently undercount in the popover.
- pr-ci-popover.tsx: drop the dead "icon" initializer in PRReviewRow
  (every branch overwrites it). Memoize useAddCheckToContext so
  PRWorkflowRow's reference equality stays stable across renders.
- pr-topbar-button.tsx: collapse the duplicate JSDoc on
  usePopoverInteractions.

* fix(github): address remaining bot review comments + tests

Real correctness fixes:
- Replace the "0/0 means counts omitted" heuristic with an explicit
  PRStatus.ChecksPopulated flag so the rich REST sync can clear stale
  totals when checks are removed from a PR upstream.
- Refresh tp.BaseBranch from status.PR on each SyncTaskPR so a
  retargeted PR's branch-protection lookup keys off the new branch.
  Persist base_branch in UpdateTaskPR.
- Cache 403 from /branches/.../protection (token lacks Administration:
  Read scope) the same way as 404 — otherwise every poller tick burns
  rate-limit quota retrying. Both PATClient and GHClient.
- usePRCIPopover: refetch on every popover-open transition (not just
  on updated_at change), via a wasEnabledRef gate. Restores the
  intended SWR semantics that the lastSyncedRef-only guard suppressed.
- groupChecksByWorkflow: prefer the URL of a job matching the group's
  bucket so clicking a "failed" workflow row navigates to a failing
  job, not an earlier passing one.

Tests:
- branch_protection_test.go: cache hit/miss/TTL/eviction, error
  not cached, no-rule cached, client-without-fetcher path.
- pat_client_test.go: TestPATClient_FetchBranchProtection table-test
  for 200/200-with-null/404/403/500.
- service_test.go: ChecksPopulated preserve-on-zero, accept real
  zero, base-branch retarget, change-publish for the new fields.

* refactor(github): trim associateTaskPR + drop unreachable PRAggregatePopover

- mock_controller.go: extract associateTaskPRRequest struct,
  buildTaskPRFromRequest, and ensureMockPRForRequest helpers so the
  handler is back under the 80-line Go limit.
- pr-ci-popover.tsx: remove unreachable PRAggregatePopover export.
  Multi-PR tasks currently use the existing DropdownMenu trigger
  (no hover popover), so the aggregate component was dead code.

* fix(github): drop unused anchorRef + tests for is{NotFound,Forbidden}Err

- pr-topbar-button.tsx: anchorRef was leftover from the dropped
  click-to-pin onInteractOutside guard. Removed.
- gh_client_test.go: tests for the new isNotFoundErr / isForbiddenErr
  helpers covering each accepted format and a few non-matches so a
  future gh CLI wording change is caught.

* fix(github): UnresolvedReviewThreadsPopulated guard + drop unreachable nil checks

- service.go: mirror the ChecksPopulated/preserve dance for
  UnresolvedReviewThreads. The REST single-PR poller (getPRStatus)
  doesn't fetch review threads, so without this guard every REST
  sync would clobber the GraphQL-populated value to 0 and emit a
  spurious github.task_pr.updated event. The GraphQL path
  (convertBatchedPRResult) sets the new flag.
- branch_protection.go: drop unreachable s == nil / fetcher == nil
  checks. fetchRequiredReviews is only invoked from SyncTaskPR on a
  service constructed via NewService, which always initialises the
  cache. After a successful type assertion, the interface value
  cannot be nil; the !ok branch already covers nil-interface inputs.
- service_test.go: new TestSyncTaskPR_UnresolvedThreads_PreservesOnLightweightSync
  covers the regression. Existing NewFieldsPublishEvent threads-change
  case updated to set UnresolvedReviewThreadsPopulated.

* refactor(web): drop CI popover progress bar — last-updated is enough

The animated progress bar was visual noise; the "updated Ns ago"
timestamp tells the user the same thing without the indicator. Footer
also hides entirely until the first fetch lands so an empty popover
doesn't show a useless "" line.

* fix(web): background-sync PRFeedback so popover is fresh on first hover

The on-hover SWR fetch was the only refresh path, so opening the popover
showed the previously-cached value while a new fetch raced behind it —
visibly stale until the refetch landed. The button icon, by contrast,
updates the moment a github.task_pr.updated WS push lands.

Add a usePRFeedbackBackgroundSync hook mounted at the PRTopbarButton
that subscribes to the same pr.updated_at change signal and refetches
PRFeedback into the cache in the background. The popover hook still
fires an on-open refetch as a safety net for the very first hover
(before any sync). No extra polling — fetches are gated on real
TaskPR changes, matching the icon's cadence.

* feat(web): CI status chip in chat status bar + tweak elapsed label

- New <PRStatusChip> renders a compact colored circle in the chat
  status bar (left of the TodoIndicator, just above the input). Hover
  opens the same PRCIPopover anchored to the chip's top edge so the
  card expands upward — matching the user's "open bottom-up" ask. The
  chip mounts the background-sync hook so the popover is fresh on
  first hover even when the top-bar PR button isn't visible.
- elapsedLabel: "just now" → "updated just now" so the footer reads
  the same regardless of how recently the cache was refreshed.

* fix(web): chip color was missing — use static class map for Tailwind JIT

\`getPRStatusColor().replace("text-", "bg-")\` produced class strings at
runtime, which Tailwind's content scanner doesn't pick up — the chip
rendered with no background. Replace with a static lookup so each
literal `bg-*` class appears in the source and gets compiled into the
bundle.

* fix(web): chip uses currentColor SVG so the dot actually renders

The previous bg-* approach relied on Tailwind compiling the literal
class strings. Even with a static map some palette entries (notably
bg-muted-foreground) didn't surface as a usable utility, so the chip
remained transparent. Apply getPRStatusColor's text-* class to the
button wrapper and fill an SVG circle with currentColor — the text-*
utilities are already compiled (PRTaskIcon uses them) so the dot
is guaranteed to render in the right color.

* fix(web): redesign PR status chip — clipboard icon + status glyph

- Move chip to right of TodoIndicator (was left).
- Render as "[clipboard icon] [status glyph]" instead of a bare circle:
  passed → green CircleCheck, failed → red CircleX, in_progress →
  yellow Loader2 (spinning), merged/neutral → muted dot. Status icons
  reuse already-compiled text-* utilities so colors render reliably.

* fix(github): ReviewCount = approved-reviewer count + ReviewCountsPopulated guard

Two bugs feeding the popover's "Approved (0)" misreport:

- Semantic: REST path set ReviewCount to len(reviews), which double-counts
  authors who left multiple reviews and includes COMMENTED rows. Replace
  with countApprovedReviewers — distinct authors whose latest review
  state is APPROVED. GraphQL converter gets the parallel
  countApprovedReviewerNodes helper and also populates PendingReviewCount
  from ReviewRequests.TotalCount (was unset, so the chip's "(K requested)"
  suffix was always missing).

- Clobber: GraphQL polls used to write ReviewCount/PendingReviewCount=0
  on every cycle because the converter never set them. Add
  ReviewCountsPopulated to PRStatus and gate SyncTaskPR's writes on it
  the same way ChecksPopulated already does, so a partial sync path
  preserves the persisted values instead of resetting them to zero.

* fix(web): popover nitpicks (icon, review row, indent, badge)

- Chip icon: ClipboardCheck → ListCheck.
- PRReviewRow always renders (drop the early-return). Fresh PRs with
  no approvals + no protection rule now show "Awaiting review" instead
  of vanishing.
- Indent workflow rows under the In Progress / Failed group headers
  (pl-5) so the hierarchy reads more clearly.
- In-progress workflow badge: "0/1 ran" → "1 running". The old phrasing
  hid whether the job had even started.

* feat(web): stacked pass-rate progress bar in CI popover

Mirrors TodoIndicator's progress bar at the top of the CI section, but
split into three coloured segments:
  green (passed) | yellow (in_progress) | red (failed)
sized proportionally to each bucket's count. The label reads
"Pass rate · K/N (P%)" so the user gets the same at-a-glance summary
TODOs shows. Skipped/cancelled-but-not-failed jobs aren't counted —
the bar always reflects the actionable picture.

* fix(web): right-align review counts + drop chip hover bg

- Move approval/required counts to the right edge of the review row
  ("Approved" on the left, "1 / 2" on the right), mirroring the
  bucket-row layout. The "(K requested)" suffix becomes a "· K requested"
  trailing fragment on the right side.
- PRStatusChip: drop hover:bg-accent/50 so the trigger doesn't grow a
  background plate when the popover is open.

* fix(web): switch CI chip icon to IconChecklist

* fix(github): clear bot review-comment backlog

Real correctness fixes:
- pr-status-chip: pending checks / pending review now beat
  checks_state === "success" in chipStatus precedence — a PR with
  CI green but reviewers outstanding is no longer rendered as
  "passed".
- branch_protection: drop the unreachable Reset() method.
- pr-topbar-button: stop double-mounting usePRFeedbackBackgroundSync;
  the chip already covers it.
- use-pr-ci-popover: dedupe key combines prFeedbackKey + updated_at
  so switching tasks with same-timestamp PRs still refetches.
- use-pr-ci-popover: drop stale comment about the removed progress bar.

Refactor:
- client_helpers + graphql: extract reviewSample +
  latestReviewStateByAuthor helper so the three near-identical
  per-reviewer dedup loops share one implementation.

Tests:
- countApprovedReviewers + reduceReviewSummary unit tests.
- TestSyncTaskPR_ReviewCounts_PreservesOnLightweightSync +
  AcceptsRealZero mirror the existing ChecksPopulated guards.
- groupChecksByWorkflow distinct-URL test verifies the failed-job
  URL-selection fix.
- branch_protection TTL tests now use testing/synctest — instant
  virtual time, no real-sleep flake on CI.

* fix(web): clicking the CI chip no longer closes the popover

HoverCard treats the trigger button as outside the content's bounding
box, so a click on the chip fired Radix's onPointerDownOutside and
auto-closed the popover. Add a triggerRef + onPointerDownOutside guard
on HoverCardContent that filters out clicks landing on the trigger —
clicking the chip is now a no-op while the popover stays open via hover.
2026-05-11 16:55:58 +01:00
José Almeida 1733014721 feat(web): prettify Kandev MCP tool titles in chat (#858)
* feat(web): prettify Kandev MCP tool titles in chat

Render `create_task_kandev` as "Kandev: Create Task" instead of the
raw underscored tool name. Frontend-only display transform via a
regex-gated helper so non-Kandev tool names (Read, Bash, other MCP
servers, Claude-supplied human titles) pass through unchanged.

* fix(web): update e2e assertions for prettified Kandev tool titles + trim pass-through

- E2E `config-management.spec.ts` asserted raw `*_kandev` names in chat;
  update to the new "Kandev: ..." display so Shard 6/10 passes.
- `prettifyToolTitle` now returns `trimmed` instead of `raw` on the
  non-match path so whitespace handling is symmetric across both
  branches (Greptile review).

* fix(web): expand collapsed tool-call groups in e2e config-management assertions

Once a turn completes, consecutive tool-call messages collapse into a
"{N} tool call(s)" group header, hiding the individual prettified titles
the test asserts against. The previous tests passed by lucky substring
match against the script directives in the user prompt; once
prettification removed `*_kandev` from the chat surface that
coincidence broke. Add an `expandToolCallGroups` helper that clicks
each group header before running the title visibility assertions.

* refactor(web): trim ACRONYMS to only entries used by current Kandev tool catalog

Per "no speculative code" guideline (Claude review): only `mcp` is
produced by the current 29-tool catalog. Remove the unused
id/url/api/ui/vs/ide/cli entries; add new ones when a catalog tool
first needs them.

* fix(web): strip MCP namespace prefix when prettifying Kandev tool titles

Codex passes the tool name as `<server>/<tool>` (e.g.
`kandev/get_task_plan_kandev`), so the bare-suffix regex didn't match
and the raw underscored name leaked through to the chat. Split on
`/` or `__` and prettify the trailing segment, covering Codex's
`server/tool` and Claude's `mcp__server__tool` naming alike.
2026-05-10 18:50:32 +01:00
Luan Martins 1066ac5b16 feat: extend implement plan button with fresh-agent path and server-side plan mode (#832)
* feat: extend implement plan button with fresh-agent path and server-side plan mode

- Split-button: primary click = same-session, chevron = fresh agent with clean context window
- Forward draft attachments and context files on implement (adds getAttachments() to handle)
- Authoritatively clear plan_mode via new session.set_plan_mode WS endpoint, not a message flag
- Add on_exit:disable_plan_mode to plan-and-build.yml plan step (mirrors feature-dev.yml)
- Drop localStorage plan-mode rehydration; server session metadata is the single source of truth
- Delete dead DocumentPanel component (no callers)
- Add SetSessionPlanModeByID on orchestrator Service; wire wsSetPlanMode handler

* test: fix store isolation in useImplementFresh test + extract duplicate strings

- Fix test isolation: reset store directly and clear mocks before rendering
- Extract sessionId, appFilePath, appFileName to constants to fix ESLint duplicate-string warnings

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(e2e): cover implement-plan fresh-agent split-button path

Adds Playwright coverage for the new chevron menu on the violet Implement
button: asserts a brand-new TaskSession is launched on the same task with
the planning session's agent profile inherited, and that the planning
session is left running in parallel. Second spec verifies the split-button
is gated behind plan mode.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: defer plan-mode exit and surface error toast on implement failures

Move handlePlanModeChange(false) into the message.add success callback in
useImplementPlan so a failed send leaves the plan layout intact for retry.
Add error toast to useImplementFresh catch so launchSession failures are
visible to the user instead of silently logged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: guard fresh-agent launch on planning session executor_id

TaskSession.executor_id is optional, so the fresh-agent path could call
launchSession with an undefined executor and silently inherit the
workspace default instead of the planning session's executor. Add the
field to the early-return guard so the hook no-ops when the planning
session is missing either an agent profile or an executor.

* feat: mark fresh-agent session as primary and focus it

When useImplementFresh launches a new implementation session, the fresh
session is now automatically:
1. Set as primary via session.set_primary WS endpoint
2. Focused/selected as the active session in the UI

This improves UX by switching the focus to the fresh implementation context
immediately rather than keeping the planning session active.

Addresses comment in PR #832 from carlosflorencio.

- Extract setupFreshSession helper to keep complexity under 15
- Add tests for primary session marking and UI focus
- Add test for graceful handling of set_primary failures
- Update JSDoc to document the new behavior
- Extract test constants to avoid duplicate string warnings

* fix(test): replace self-referential test constants

`const TASK_ID = TASK_ID;` (and SESS_PLAN, SESS_FRESH) caused a TDZ
ReferenceError at module load, so vitest reported `(0 test)` and none of
the new fresh-agent coverage actually ran. Replace with literal values
so the 13 tests in this file execute again.

* fix: drop strict executor_id guard and skip composer clear on missing session_id

Two related issues:

1. e2e regression — the planning session in the FE store does not always
   carry executor_id (the field is omitempty on the backend DTO and isn't
   populated for fresh task sessions in the WS push path). The earlier
   guard required it before calling launchSession, so the fresh-agent
   path silently no-op'd in real flows. Drop executor_id from the guard
   and only forward it when set; backend resolves it from the agent
   profile / workspace default when omitted.

2. PR review (Greptile P1) — when launchSession resolves without a
   session_id, the previous flow still cleared the composer and draft
   even though no fresh session was activated. Bail out before the
   clear so the user keeps their typed text and can retry.

Tests updated:
- replace the no-op-on-missing-executor test with one that asserts the
  hook still launches and omits the key from the payload
- add coverage for the no-session_id branch keeping the composer intact

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 17:30:24 +01:00
Carlos Florêncio 40c89cd380 fix(web): preserve dockview state across task and plan-mode switches (#855) 2026-05-09 11:59:16 +01:00
Carlos Florêncio 9664e13f07 fix(web): keep "All Workflows" filter on task nav and workflow.created (#854) 2026-05-08 21:23:48 +01:00
Carlos Florêncio 7fe2affd6d fix: respect "All Workflows" selection with multiple workflows (#844) 2026-05-07 23:20:30 +01:00
Carlos Florêncio 0d3fce25b4 fix(web): stop dockview layout corruption when switching between maximized / sessionless tasks (#838) 2026-05-07 18:43:07 +01:00
Carlos Florêncio 8c70aee27d fix(workflows): hide improve-kandev system workflow from settings UI (#842) 2026-05-07 18:42:57 +01:00
José Almeida 7ec639bf2f feat(web): mobile parity (session/terminal/repo pickers + multi-terminal) (#840)
* refactor(web): extract shared use-session-actions hook from SessionTab

Move WS-level session lifecycle handlers (set primary, stop, resume,
delete) into hooks/domains/session/use-session-actions so the same logic
can power both the dockview tab context menu and upcoming mobile
controls. Dockview-specific panel removal stays local in SessionTab via
the onDeleted callback.

* feat(web): humanize task and session state labels

Mobile task sheet was rendering raw enum values like "IN_PROGRESS".
Add formatTaskStateLabel + formatTaskSessionStateLabel helpers and use
them in the mobile task sheet badge. Session state badge in the new
mobile sessions picker also reuses the formatter.

* feat(web): mobile session/terminal/repo pickers + multi-terminal support

Address parity gaps on mobile by introducing per-pane header pills that
open bottom-sheet pickers, scoped to the relevant view rather than
overloading the left task sheet.

Pickers:
- Sessions pill in chat header → bottom sheet with launch / set-primary
  / stop / resume / delete (with confirm). Pill shows agent label +
  idx/total badge.
- Terminals pill in terminal header → bottom sheet with add / close
  (with confirm) / switch. Replaces an earlier chip-strip prototype.
- Repos pill on the top bar (multi-repo only) → bottom sheet listing
  task repositories; switching jumps to a session on that repo.

Each pill renders with a chevron-down icon + count badge so the
trigger reads as a button, not a status badge.

Multi-terminal:
- New useMobileTerminals hook wraps useTerminals and auto-creates a
  first shell when the env reports zero.
- MobileTerminalPane multi-mounts PassthroughTerminal instances keyed
  by terminalId so switching preserves scrollback. Active xterm
  registers itself in a small mobile-active-terminal sender registry;
  the existing key-bar reroutes input through xterm.paste() so Ctrl /
  ^C / arrows reach whichever terminal is currently visible, not just
  the per-session default shell.
- Skip the WebGL renderer addon on mobile (new disableWebgl prop on
  PassthroughTerminal). The WebGL atlas miscomputed glyph scaling
  after the desktop→mobile responsive switch and rendered text at
  several times the intended font size; the canvas renderer is correct
  and fast enough for shell use.

Refactors:
- Left sheet reverts to a single-purpose task list (the sectioned-hub
  variant was rolled back in favour of per-pane pickers).
- session-task-switcher-sheet-hooks extracted from the sheet to keep
  it under the file-size lint cap.

* fix(web): address review feedback on mobile pickers + multi-terminal

Bots flagged a real first-mount bug and a handful of smaller items;
addressed each:

- mobile-terminal-pane: track xterm in state instead of a ref so the
  active-sender registration effect re-runs once xterm is ready. With
  the ref, the effect ran once on mount, found xtermRef.current === null
  while PassthroughTerminal was still initializing (notably under the
  ResizeObserver fallback path when the container starts at 0×0), and
  silently never registered — leaving the key-bar wired to the default
  shell instead of the visible terminal.
- send-shell-input: wrap the activeSender call in try/catch so a stale
  registry entry (xterm GC'd mid-frame) falls back to the WS path
  instead of dropping the keystroke and silently consuming modifiers.
  Added tests for the activeSender routing branch (success, modifier
  consumption, fallback on throw).
- mobile-sessions-section: remove the dead `showCloseConfirm ? "Delete" : "Delete"`
  ternary and the unused prop on SessionActionsMenu.
- use-mobile-terminals: clear the auto-create guard when addTerminal()
  rejects so the user gets a retry instead of an empty terminal list.
- mobile-repo-pill: sort taskRepos by position before falling back to
  the first repo, so the pill label matches the picker's ordering when
  no session is active yet.
- session-task-switcher-sheet-hooks: catch loadTaskSessionsForTask
  rejection in handleSelectTask (was an unhandled rejection that left
  the sheet open on backend errors), and stabilize useCallback deps in
  handleWorkspaceChange by spreading the SheetNavOptions fields so a
  fresh literal each render doesn't defeat memoization. Extracted
  selectTaskWithoutPrimarySession to keep useSheetActions under the
  function-size lint cap.

* fix(web): preserve task.repositories in sheet-hooks kanban mappers

mapSnapshotToKanban (workspace switch) and handleTaskCreated (newly
created task) only carried the first repository_id, dropping the full
TaskRepository array. The mobile repo picker (useTaskRepoCount +
MobileReposSection) reads task.repositories.length, so it silently
disappeared after a workspace switch and for freshly created
multi-repo tasks. Map the array with the same shape the SSR mapper
uses (id, repository_id, base_branch, checkout_branch, position).

Also address a small nit: wrap addTerminal in an arrow at the New
terminal button so the click MouseEvent isn't passed as the (unused)
first argument.

* fix(web): de-dup mobile terminal auto-create + add state-label tests + repo-tap toast

- use-mobile-terminals: move the auto-create-first-shell guard from
  per-instance refs to a module-level Set keyed by environmentId.
  MobileTerminalPane mounts useMobileTerminals three times for the
  same session (the pane itself, the picker pill via
  useActiveTerminalPillLabel, and the picker sheet content via
  MobileTerminalsList — Vaul keeps it mounted while closed). With per-
  instance guards, all three effects ran on first commit and each
  fired createUserShell, racing into up to three shells. The shared
  Set ensures only the first effect to observe an empty list creates,
  with retry-on-failure preserved (delete + next render re-checks).
- mobile-repos-section: tapping a repo with no sessions now toasts a
  hint to open the session picker, instead of silently doing nothing.
- state-labels: add tests covering known mappings, null/undefined
  fallbacks, and unknown-state passthrough for both formatters.

* fix(web): single useMobileTerminals via context + tests + drop dead export

Address two follow-up review items:

- Lift useMobileTerminals to a single MobileTerminalsProvider owned by
  MobileTerminalPane. The picker pill and the picker sheet's list now
  read state from useMobileTerminalsContext instead of calling
  useMobileTerminals themselves. The previous setup had three
  independent useState-backed lists that drifted out of sync after
  addTerminal/removeTerminal — the pane could show a stale active
  terminal and the pill count could lag until a server WS push
  reconciled them. The module-level guard (added in 9488bb0c) fixed
  the creation race; this fix ensures the live list stays consistent
  too.
- Drop the dead `subscribeActiveTerminalSender` export from the mobile
  active-terminal registry. The keybar reads via getActiveTerminalSender;
  notify/subscribe was speculative scaffolding with no production
  consumer. Test trimmed accordingly.
- Add use-mobile-terminals.test.ts covering the auto-create logic
  (initial create, skip when shells exist, skip while loading,
  multiple instances trigger only one creation, retry after failure).
  Module-level guard exposes a __resetAutoCreatedEnvironmentsForTest
  helper so tests start from a clean slate.

* test(web): capture binary AttachAddon frames in mobile keybar e2e helper

The mobile-terminal-keybar Playwright spec asserted on captured
shell.input WS frames. After this PR, the on-screen mobile terminal is
a PassthroughTerminal with its own AttachAddon WebSocket, and the
keybar routes input through xterm.paste() — bytes go on the binary
terminal WS, not the kandev gateway's shell.input action. The capture
helper missed those frames and every keybar-asserts-on-frame test
failed (Ctrl+L, Shift+Tab, Esc, arrows, symbols, etc.).

Extend attachShellInputCapture to also decode binary terminal frames
(skipping the resize tag 0x01 prefix used by useSendResize) so the
existing assertions on `data` continue to work regardless of which
transport carried them.

* fix(web): keybar control chars + session pill propagation + terminal close ordering

Round-3 review feedback. Several real bugs that the previous rounds'
mocks hadn't caught:

- mobile-terminal-pane: switch from xterm.paste(data) to
  xterm.input(data, true). paste() wraps the payload in bracketed-paste
  escapes (\\e[200~…\\e[201~) under bash/zsh/fish, so every control
  byte from the keybar (^C, arrow keys, Esc, ^D, Tab) was landing in
  the shell as literal pasted text instead of as a control signal.
- session-mobile-layout: TaskChatPanel and the agent PassthroughTerminal
  were rendering with the URL-derived `sessionId` prop instead of the
  store-driven `effectiveSessionId`. The session picker would update
  the top bar and terminal pane (which already read effectiveSessionId)
  but the chat content and passthrough agent stayed on the old session
  until a navigation refresh. Both consumers now follow
  effectiveSessionId; the now-unused `sessionId` prop is removed from
  MobileChatPanelContent + MobilePanelArea.
- mobile-terminals-section: rework handleConfirmClose. Stop the remote
  shell first (and only mutate the picker on success), advance
  setRightPanelActiveTab to a remaining terminal when the closed one
  was active so terminalTabValue doesn't dangle, and call
  releaseAutoCreatedEnvironment when terminals drop to 0 so the pane
  recreates a default shell on next render instead of getting stuck
  on "Starting terminal…". Extracted as a useTerminalCloseHandler hook
  to keep the list under the function-size lint cap.
- session-task-switcher-sheet-hooks: handleTaskCreated now merges
  session-derived fields (primarySessionId, sessionCount, etc.) from
  the existing kanban entry, with meta.taskSessionId as the priority
  source — so an "edit" call doesn't wipe the existing session and a
  "create with session" call sets primarySessionId correctly.
  Extracted into buildKanbanTaskUpsert + mergeSessionFields +
  mapTaskRepositories helpers.
- session-task-switcher-sheet-hooks: mapSnapshotToKanban now carries
  optional step fields (allow_manual_move, prompt, is_start_step,
  show_in_command_panel, agent_profile_id) so the kanban store keeps
  step capabilities after a workspace switch.
- use-mobile-terminals: add releaseAutoCreatedEnvironment(envId) — a
  runtime API to clear the module-level guard for one env, used by the
  picker when the user closes the last terminal.
- use-mobile-terminals.test: extract SESSION_ID constant.

* fix(web): restore mobile keybar modifier transforms with manual input routing

Round-3 swapped mobile from ShellTerminal → PassthroughTerminal, which
made AttachAddon auto-send xterm.onData straight to the WebSocket.
That bypassed sendShellInput's modifier transform — so OS-keyboard
input on mobile (e.g. tap Ctrl on the key-bar, then press "l") sent
the raw "l" instead of "\\x0c" (form feed). Multiple e2e keybar tests
caught this; my prior xterm.input(data, true) attempt only fixed the
key-bar tap path, not OS-keyboard typing.

Add `manualInputRouting` + `onWsReady` props to PassthroughTerminal
(plumbed through useWebSocketConnection / startReconnectLoop /
connectWebSocket). When `manualInputRouting` is true, AttachAddon is
constructed with `bidirectional: false` so it only handles PTY → screen,
and the consumer is responsible for forwarding xterm.onData itself.

Mobile TerminalSlot now:
- passes manualInputRouting=true and an onWsReady callback that
  registers an active-terminal sender writing raw bytes to that
  terminal's dedicated WS (replacing the prior xterm.input registry).
- attaches its own xterm.onData listener that calls sendShellInput,
  which applies the key-bar's sticky Ctrl/Shift transforms before
  forwarding via the registered sender.

Net effect: both key-bar taps AND OS-keyboard typing flow through
sendShellInput on mobile, so modifiers apply uniformly. Desktop
unaffected (default bidirectional AttachAddon path unchanged).

Also extracted teardownWebSocket into ws-reconnect to keep the
use-passthrough-terminal file under the line-count cap after the
new options.

* fix(web): tighten mobile sender registration + Set Primary + launch return

- mobile-sessions-section: drop the borrowed isSessionStoppable guard
  on the "Set as Primary" item. The backend session.set_primary action
  has no such restriction, so a COMPLETED or FAILED session was being
  silently grayed out. Disable only when isPrimary or state isn't
  loaded yet.
- mobile-terminal-pane: register the active key-bar sender only while
  the dedicated WebSocket is OPEN. Previously the sender went into the
  registry as soon as the tab became active, so during connect /
  reconnect it would silently no-op instead of letting sendShellInput
  fall through to its WS path. Track ws in state, drop it on close,
  and gate registration on readyState === OPEN.
- session-task-switcher-sheet-hooks: early-return after a successful
  launchSession so we don't redundantly call setActiveTask after
  setActiveSession. Mirrors the existing "sessions exist" branch.
2026-05-06 19:05:21 +01:00
João Salavisa aa6023e421 feat(web): per-task color indicator in sidebar (#835)
* feat(web): per-task color indicator in sidebar (localStorage)

Adds a color marker to tasks in the left task list for better visual
identification. Right-click a task → Color submenu picks one of seven
colors (or None to clear). Color renders as a 3px bar on the left edge
of the row and persists in localStorage.

* fix(web): address review feedback on task color indicator

- Filter storage events to TASK_COLORS_STORAGE_KEY so cross-tab writes
  to unrelated keys no longer wake every TaskItem subscriber.
- Cache the parsed color map at module level, invalidated on writes and
  cross-tab storage events. Avoids re-parsing localStorage on every
  getSnapshot call (N tasks × N renders).
- Drop redundant typeof string check on Object.entries keys.
- Dim the colored bar to opacity-60 when not selected so colored rows
  remain distinguishable from colored-and-selected rows at the left edge.

* refactor(web): remove test-only export from task-colors

Drop the __resetTaskColorsCacheForTests escape hatch from the
production module. Tests now invalidate the cache via a synthesized
cross-tab `storage` event, exercising the same listener path the
production code uses for real cross-tab updates.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-06 19:05:19 +01:00
Carlos Florêncio b1cd2d2076 feat(github): rate-limit awareness, poller throttling, and GraphQL batching (#821) 2026-05-06 16:51:06 +01:00
João Salavisa 9fe2cbd874 fix: source commit pushed status from git remote, not PR commits (#833)
* fix: source commit pushed status from git remote, not PR commits

Commits showed as "not pushed" whenever the branch had no open PR,
since the UI derived pushed status by matching local SHAs against
the PR's commit list. Sourcing it from the upstream tracking ref in
agentctl's GetLog keeps the answer correct without a PR, after
rebases, and across multi-repo workspaces.

* fix: treat PR-matched commits as pushed alongside the git remote signal

mergeCommits now flags a commit as pushed when EITHER the backend's
git-sourced flag is true OR a PR commit matches by SHA. The git signal
fixes the original "no PR = unpushed" bug; the PR signal preserves the
"PR exists ⇒ commit is on remote" assumption that the E2E mocks rely
on (and that holds in practice, since GitHub PRs require pushed
branches). Also corrects the validation-path comment in
markPushedCommits to describe the actual non-branch fall-through.

* perf: bound rev-list walk in markPushedCommits to result length

Without -n the walk was unbounded — a branch with many local-only
commits would traverse full unpushed history per GetLog call. Capping
at len(commits) keeps the two walks proportional. rev-list walks
newest-first like GetLog, so the cap covers every commit we mark.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-05 22:09:02 +01:00
João Salavisa 5e3248a66b feat(web): pin tasks and drag-to-reorder in sidebar (#829)
Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-05 22:06:24 +01:00
João Salavisa 5f4b3ec0c0 feat(web): allow renaming quick-chat tabs locally (#830)
* feat(web): allow renaming quick-chat tabs locally

Double-click a quick-chat tab to rename it. The name is stored in
localStorage and overlays the SSR-derived task title on hydration, so
renames survive reload without touching the backend.

* fix(web): address quick-chat rename review feedback

- Drop unused sessionId prop from QuickChatTabItem
- Enter handler now blurs the input instead of calling commit(),
  preventing a duplicate commit when onBlur fires on unmount
- Add hydrator tests covering the localStorage name overlay

* fix(web): make Escape actually cancel quick-chat rename

Escape was setting isEditing(false), which unmounted the focused input,
causing onBlur (commit) to fire synchronously with the typed draft still
in the closure — committing the rename instead of discarding it.

Cancel now flips a cancelledRef and blurs the input; commit short-circuits
when the ref is set, so onBlur is the single edit-exit path for both
Enter and Escape. Adds a render test covering both flows.

* fix(web): guard quick-chat rename against IME and orphan storage writes

- Skip Enter/Escape handling while IME composition is active so candidate
  confirmation does not commit/cancel the rename
- renameQuickChatSession only writes to localStorage when the session was
  actually found in state, preventing orphan entries for stale session IDs

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-05 21:47:35 +01:00
José Almeida bce9fd266a feat: multi-question support for ask_user_question_kandev (#828)
* feat(backend): multi-question support for ask_user_question_kandev

Reshape the MCP tool from a single prompt+options pair to a questions[]
array (1-4 entries, single-choice each, custom text fallback). The
clarification HTTP handler gates the response until every question has an
answer, fans out one chat message per question (sharing pending_id, with
question_index/question_total in metadata), and returns a JSON map keyed
by question id back to the agent. The orchestrator resume prompt now
formats multi-line Q1/A1, Q2/A2 summaries when the bundle has more than
one question. Mock-agent gets a clarification-multi scenario and
ask_user_question_test.go covers the new schema + map response. No
back-compat shim — local-first tool, single-PR breaking change.

* feat(web): refresh clarification overlay with multi-question + modern UI

Each per-question chat message gets its own card with a "Question N of M"
progress chip, a ✓ Answered badge once committed, and shadcn-aligned
option buttons with kbd hints. A new useClarificationGroup hook coordinates
the per-question commits (decision A) and posts every answer in a single
batch once the user finishes the bundle; X rejects the whole group. The
single-question case keeps the 1-9 / Esc keyboard shortcuts. Playwright
e2e adds 6 multi-question scenarios (rendering, full happy path, partial
state, mixed option/custom, skip-rejects-bundle, sibling-state) on top of
the existing single-question regression suite.

* fix(web): clarification carousel UX with stepper navigation + race fix

Replaces the vertically stacked card layout with a horizontal stepper:
numbered circle dots up top show step state (active/answered/pending),
the single active question card sits in the middle, and Back / Next /
Submit buttons sit at the bottom. Picking an option auto-advances to the
next step (single-question case auto-submits, no explicit Submit button).
The stepper steps are clickable so users can revise prior answers.
Keyboard shortcuts: 1-9 for option pick, ArrowLeft/Right for navigation,
Esc for skip.

Also fixes a race that surfaced as a 400 BadRequest on the
/api/v1/clarification/:id/respond endpoint when the user clicked an
option before all N messages of a multi-question bundle had streamed in:
findPendingClarificationGroup now gates on metadata.question_total and
returns an empty array until the full bundle has arrived.

Playwright suite expanded from 6 to 12 multi-question scenarios:
stepper rendering, auto-advance, Back, stepper jump, Submit gating,
happy path, mixed custom-text, revise-via-jump, skip-from-mid-step,
number-key shortcuts, Esc skip, first-step Back disabled.

* fix(clarification): address CodeRabbit/Greptile/Claude review comments

Backend:
- Single source of truth for question validation: rename/export
  NormalizeAndValidateQuestions and call it from both the HTTP and the
  WebSocket-side MCP handlers, so duplicate question ids and bad option
  counts can't slip through either path.
- httpRespond now validates that every answer references a real question
  id from the bundle and that no id is answered twice (regression tests
  added).
- questionsFromMessages reorders by metadata.question_index so the
  reconstructed bundle preserves the original question order.
- adapters.CreateClarificationRequestMessages rolls back already-created
  messages when a later write in the bundle fails.
- extractQuestionAnswers always returns a JSON map (even on rejection,
  with an envelope { rejected, reject_reason }) and simplifyAnswer
  preserves both selected_option and custom_text when both are present.
- MCP tool description: rewrite the response example to match the actual
  output shape; add minItems / maxItems to the questions and options
  arrays in the JSON Schema.
- Test helper: drop the local contains() shim, use strings.Contains.

Frontend:
- useMemo([props]) was a no-op (props is always a fresh ref); split into
  [message, messages] so it actually memoizes.
- Drop the dead "expired" SubmitState branch — the backend never returns
  410 from this endpoint.
- Remove the asymmetric option-list dimming: the agent now receives both
  selected_option and custom_text together, so a half-grey state was
  misleading.
- New tests: pending-clarification.test.ts covers
  findPendingClarificationGroup including the question_total gate, and
  use-clarification-group.test.ts covers the hook's record/submit/skip
  flows and the override-merge path used by the single-question case.

* fix(web): treat clarification 409 as success so duplicate submit doesn't stick the overlay

* fix(clarification): unblock agent on persistence failure + nil-guard rejected branch

- Add Store.CancelRequest(pendingID) and call it from both the WebSocket
  MCP handler and the HTTP create-request handler when message creation
  fails. Without it, the agent's WaitForResponse parked for the full
  2-hour MCP timeout while no clarification cards ever reached the user.
  Both handlers now return an explicit error response on failure.
- applyAnswersToMessages now guards h.repo == nil in the rejected branch
  (parity with sibling expectedQuestionIDs).
- extractQuestionAnswers stubs for unanswered questions in a rejected
  bundle now carry { answered: false, rejected: true } so agents reading
  per-question payloads see the rejection without inspecting the
  envelope.
- Hook test: added 409-as-ok regression coverage for both submit and
  skip paths so the previous fix can't drift.
- Store test: covers CancelRequest unblocking a parked WaitForResponse.

* fix: drop unused e2e helper + use channel sync in CancelRequest test

* refactor(web): drop dead 'message' prop from clarification overlay

Both call sites pass 'messages' (the bundle group), so the legacy
single-message prop was unused. Tightening the prop type also lets the
useMemo depend on a single value.

* fix(web): ignore modifier-keyed events in clarification carousel shortcuts

Bare 1..9 / ArrowLeft / ArrowRight / Esc still drive the carousel, but
Cmd/Ctrl+1..9 (browser tab switch) and Alt+ArrowLeft (browser back) now
pass through as the user expects.
2026-05-05 19:14:57 +01:00
José Almeida c64e835612 feat: allow moving tasks across workflows (#822)
* feat: allow moving tasks across workflows

* fix: address PR review feedback

* fix: address remaining PR review feedback

* fix: remove unreachable move state event

* fix: map task move conflicts correctly

* fix: address PR review nitpicks

* fix: use neutral task move error message
2026-05-05 14:13:43 +01:00
João Salavisa 7e0b46c227 feat(slack): cookie-mode integration that triages threads via a utility agent (#775)
* feat(slack): add cookie-mode integration that triages messages via utility agent

Adds an install-wide Slack integration that polls the connected user's
own messages for `!kandev <instruction>`, hands the thread context to a
configured utility agent with the Kandev MCP server attached, and
creates the resulting task. Auth uses the user's xoxc- token + d cookie
so it works on locked-down Enterprise Slack tenants where bot installs
aren't possible.

Shared infra:
- ACP host-utility executor accepts McpServers on session/new
- New MCP tool list_repositories_kandev so the triage agent can pick the
  right repo
- Template engine exposes Slack-specific variables for prompt injection
- secretadapter + healthpoll reused from jira/linear

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(slack): address PR review feedback round 9

- Drop dead `var _ = time.Second` no-op and its misleading comment in
  trigger_test.go.
- `RecordAuthHealth` now invalidates the cached client when the probe
  fails, so the poller self-recovers from credential rotation without
  requiring a re-save.
- Stable secondary sort key (`workspace_id DESC`) on the legacy →
  singleton migration so promoted-row selection is deterministic on
  `updated_at` ties.
- Extract `categorySlack` constant in `engine.go` and update the
  `VariableInfo.Category` doc-comment to list the full set of values.
- Realign `slack-integration/spec.md` with what shipped: cookie-mode +
  `!kandev` triage as v1, with bot install / User OAuth / reaction &
  slash & shortcut triggers moved under Future scope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 09:03:42 +01:00
João Salavisa 83de4793fd feat: add Linear issue watchers (#805) 2026-05-05 08:28:09 +01:00
Carlos Florêncio cb0feea650 fix(web): dedup topbar PR button against auto-shown PR panel (#812) 2026-05-04 19:20:37 +01:00
Carlos Florêncio c042050469 fix: stabilize task switch flow and session recovery (#818) 2026-05-04 18:53:33 +01:00
Carlos Florêncio 39b9369ae1 fix: suppress spurious task-failed toasts on resume and reconnect (#814) 2026-05-04 17:58:59 +01:00
João Salavisa 5c5e7bf6a7 fix(changes-panel): scope commits/diff to live branch divergence; flatten single-repo (#816)
* fix(changes-panel): scope commits/diff to live branch divergence; flatten single-repo

Two bugs in the COMMITS panel made it surface commits and file changes
that were already on main:

1. The commit list and the cumulative diff both anchored to a stale base.
   Commit list used `git merge-base HEAD <local-branch>` and the file diff
   used the session's stored base SHA captured once at session start. On
   long-lived worktrees the local main ref lags far behind origin/main, so
   the merge-base lands on an old SHA; once the agent merges main back in,
   the stored base is also stale. Both end up sweeping in dozens of main
   commits as if they were the branch's work.

2. `git log <base>..HEAD` follows both parents of a merge commit by
   default, surfacing main commits brought in via merges from main.

Fixes:
- agentctl computes merge-base against `origin/<target_branch>` (with a
  local-ref fallback for unfetched/local-only branches), and uses that
  for both the commit log and the cumulative diff. Mirrors GitHub's
  three-dot PR diff semantics — anchor to live divergence with the
  upstream, not a session-start snapshot.
- `git log` now passes `--first-parent` so only the branch's own line of
  work plus its merge commits show up; commits brought in via merges from
  main are excluded.

Frontend: drop the per-repo "REPOSITORY" sub-header in the COMMITS panel
for single-repo workspaces. CommitsRepoGroup gains a `showHeader` prop;
when the workspace has one untagged group the section renders flat with
the per-repo Push / PR (and Stage all / Commit / Unstage all) buttons
lifted into the section header. Multi-repo rendering unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address PR review feedback

- Scope --first-parent to the divergence-range path only (Greptile P1).
  The flag is wrong for open-ended history calls — future activity
  widgets etc. need the full graph.
- Log empty merge-base case in cumulative diff (Greptile P2). Silent
  fallback to stored base was invisible during diagnostics.
- Rename TestRunGitCumulativeDiffForRepo_PrefersOriginRef →
  TestComputeMergeBase_CorrectAnchorForCumulativeDiff (Claude). Test
  exercises computeMergeBase, not the runGitCumulativeDiffForRepo
  conditional; new name reflects what it actually proves.
- Add TestGetLog_NoBaseReturnsFullGraph to lock in the --first-parent
  scoping.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address PR review round 2

- Drop dead ActionButtonsSection from changes-panel-timeline.tsx (unused
  export, no consumers anywhere). Brings the file from 640 → 516 lines,
  comfortably under the 600-line TS limit Claude flagged.
- Extract isSingleRepoGroup helper into lib/group-by-repo.ts and use it
  in all three sites (CommitsSection, FileListBody, FileListSection) so
  single-repo detection has one source of truth.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 17:28:04 +01:00
João Salavisa b30933240b fix: sanitize multi-repo worktree dirs and polish task-create chip dropdown (#815)
* fix: sanitize multi-repo worktree dirs and polish task-create chip dropdown

Backend: org-prefixed Repository.Name like "owner/repo" was used verbatim
as the worktree subdirectory under the task root, nesting one level too
deep and breaking agentctl's sibling-based multi-repo detection in
scanRepositorySubdirs. Sanitize to a single path-safe segment so all
worktrees land as siblings of the task root.

Web: the multi-repo chip dropdown built its own option list and dropped
the path Badge plus search filtering that the legacy header dropdown
shipped. Restore parity (name + path), widen the popover (288 → 360 px),
stack name and full path on two lines so neither has to truncate
aggressively, hide the Badge for workspace repos without a local clone,
and use a path-aware scorer that floors weak fuzzy matches so unrelated
paths stop surfacing for short queries.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address PR review feedback

- Add ErrInvalidRepoName sentinel and use it in createInTaskDir when
  RepoName sanitizes to empty, replacing the misleading reuse of
  ErrTaskDirRequired (Claude/Greptile).
- Reuse repoDirHyphenRun in SanitizeForBranch instead of recompiling the
  regexp per call (CodeRabbit nitpick).
- Document the SanitizeRepoDirName collision edge case in the doc
  comment (Greptile).
- Extract scoreRepo to lib/utils/repo-filter.ts and add unit tests
  pinning the 0.4 fuzzy-match threshold (Claude).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 17:22:28 +01:00
José Almeida b2dcd29818 fix(web): tab session bugs — primary star drift + tab close re-creation (#800)
* fix(web): preserve primarySessionId across kanban.update broadcasts

The kanban.update WS handler rebuilt kanban.tasks without
primarySessionId / primarySessionState, wiping values set by
task.updated. After any board refresh the primary star vanished and
selectTaskWithLayout fell into the slower async fallback path. Move
task building inside setState so existing values are preserved in both
single and multi snapshots.

* fix(web): prevent deleted session panel from being re-created

handleDelete called removeTaskSession before containerApi.removePanel,
which let useAutoSessionTab re-run with effectiveSessionId still
pointing at the deleted session and re-create its panel. The
setupChatPanelSafetyNet had a matching gap — it re-added a panel for
activeSessionId without checking the session still existed.

Switch the active session (or clear it) before removeTaskSession so
effectiveSessionId updates first. Add guards to useAutoSessionTab and
the safety net so neither creates a panel for a session that is no
longer in the store.

* test(web): add session tab management regression suite

Cover the three bugs fixed in this branch end-to-end: deleting a
non-active session keeps it gone, deleting the active session switches
focus to the remainder, deleting then immediately switching tasks does
not resurrect the tab, and the primary star survives both a
kanban.update broadcast (triggered via apiClient.moveTask) and a
task-switch round trip.

* fix(web): hand off deletion to newest remaining session, document multi-snapshot fallback

- session-tab.tsx: sort remaining sessions by started_at DESC and
  pick the newest, instead of relying on the unspecified store ordering.
- kanban.ts: comment the multi-snapshot primarySessionId fallback so
  the double lookup does not look like redundancy.

Both per Claude review feedback on #800.

* fix(web): preserve explicit-null primary, abort delete on WS failure

- kanban.ts: switch the multi-snapshot fallback from `??` to an
  explicit `=== undefined` check. An explicitly-cleared null primary
  must NOT be replaced by a stale snapshot value, only an absent key.
- session-tab.tsx: wsAction now returns a boolean; handleDelete bails
  out before mutating the store / dockview when the backend
  session.delete request fails. Prevents client/server desync from a
  transient WS failure.
- kanban.test.ts: add regression covering the null-vs-undefined merge.

Per CodeRabbit and Greptile review on #800.

* style(web): drop redundant .slice() in session deletion sort chain

filter() already returns a fresh array, so the subsequent sort() does
not mutate shared store state. Per Greptile review on #800.
2026-05-03 17:32:29 +01:00
João Salavisa e9a7869463 refactor(web): drop dead taskPRs loading state and unused removeTaskPR (#791)
* refactor(web): drop dead taskPRs loading state and unused removeTaskPR

The taskPRs.loaded/loading fields were written but never read — useWorkspacePRs
uses its own fetchedRef for fetch-once gating instead. removeTaskPR was defined
but never called. Removing all three avoids a footgun in multi-repo tasks where
removeTaskPR would have wiped every repo's PR state at once.

* refactor(web): use plain deepMerge for taskPRs hydration

taskPRs no longer carries loading/loaded fields, so the mergeWithLoading helper has nothing to preserve. Switching to deepMerge makes the intent explicit.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-03 12:00:22 +01:00
João Salavisa bb36cb9e01 refactor: move integrations to top-level settings with install-wide configs (#787)
* refactor: move integrations to top-level settings with install-wide configs

Jira and Linear configurations are now install-wide singletons rather than
per-workspace duplicates, and the GitHub/Jira/Linear settings pages live
under /settings/integrations instead of being nested inside each workspace.
Issue and PR watchers stay per-workspace via a workspace switcher inside
each integration page. Backend ships one-shot migrations that promote the
most-recently-updated row and copy its secret onto the new global key.

* fix: address review feedback on integrations refactor

Fix migration crash on installs whose jira_configs predate the auth-health
columns by selecting only guaranteed-present legacy columns and falling back
to defaults for the rest. Cover the empty-legacy-table and pre-health-columns
upgrade paths with new store tests.

On the frontend, refactor useIntegrationEnabled to take string parameters
instead of an inline options object so getSnapshot stays referentially stable
across renders, and stop dispatching a misleading no-op sync event when
localStorage.setItem fails in private mode.

* fix: hydrate workspace list on integration settings pages

The integration pages wrapped their content in a fresh StateProvider, which
created a nested store scope with an empty initial state and shadowed the
settings layout's hydrated workspace list. WorkspaceScopedSection then saw
zero workspaces and rendered the "Create a workspace to configure this
integration" empty state even when the user had workspaces.

Drop the redundant StateProvider from the jira and linear pages so they share
the layout's store. The github page switches to StateHydrator so it can still
seed the GitHub OAuth status without resetting the rest of the store.

* refactor(integrations): flatten watcher tables, pick workspace in create dialog

Hiding watchers behind a workspace switcher made the list invisible at a
glance — a user with multiple workspaces had to flip between each one to
see what was configured. Replace the switcher with a flat table that lists
every watch across every workspace, with a Workspace column for context, and
move workspace selection into the create dialog so the user picks once when
the watcher is born.

Backend: jira list-issue-watches and github list-review-watches /
list-issue-watches accept the workspace_id query param as optional; when
omitted, the corresponding store ListAll* method returns rows across every
workspace. Per-row mutations still require workspace_id as the IDOR guard.

Frontend: useReviewWatches / useIssueWatches / useJiraIssueWatches gain a
three-state contract — string for one workspace, undefined for all
workspaces, null to skip fetching. Watch tables grow an optional
showWorkspace column. Watch dialogs accept an optional workspaceId prop;
when absent, the dialog renders a workspace dropdown for the create flow
(disabled when editing existing rows so refs aren't orphaned). Github
review-watch-dialog crossed the 600-line lint cap so the prompt editor
moved to review-watch-prompt-field.tsx.

* fix(integrations): make watcher step dropdown reactive across workspace switches

Switching to a workspace whose workflows had to be fetched left the workflow
step dropdown stuck — the user could pick a workflow but the step Select
either showed the previous workflow's steps or appeared inert. The cause was
the per-dialog useWorkflowSteps hook deferring its empty-state reset via
Promise.resolve().then(() => setSteps([])) to dodge a setState-in-effect
lint rule, which let stale steps survive into the next render and gave Radix
nothing to anchor the popover to during the in-flight fetch.

Hoist the hook into a shared useWorkflowSteps that resets via React's
official setState-during-render pattern, exposes a loading flag, and returns
a stepPlaceholder helper so the Select can render "Loading steps…" or "No
steps in this workflow" instead of an empty popover. The Step Select is now
also disabled while loading or when there are no steps, making the state of
the dropdown unambiguous.

* fix: address PR review feedback on integrations refactor

Backend:
- linear migration: add column-presence guard mirroring the Jira fix so
  installs that upgraded from the original Linear schema (without org_slug
  / last_checked_at / last_ok / last_error) don't crash startup with
  "no such column"; add TestStore_MigrateLegacyPerWorkspaceTable_PreHealthColumns
- jira/handlers.go: drop the now-unused errMsgWorkspaceIDRequired constant
  that was failing golangci-lint
- jira/mock_controller_test.go (new): cover setAuthHealth happy-path and
  invalid-JSON-rejection so the singleton-shaped payload is locked in

Frontend:
- useIntegrationAvailable: gate the auth-health probe on loaded && enabled
  so disabled integrations don't poll /config every 90s
- use-workflow-steps: initialize loading to !!workflowId so edit-mode opens
  show "Loading steps…" instead of "No steps in this workflow" before the
  first fetch lands
- use-workflow-steps.test.ts (new): cover the empty/non-empty workflowId,
  workflow switch, fetch error, and stepPlaceholder branches
- use-linear-enabled.test: tighten the legacy-key migration assertion to
  pin the migrated value rather than just verifying key cleanup

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-03 10:22:57 +01:00
José Almeida 32f098a6fc feat: show question icon only for pending input (#782)
* fix: show question icon only for pending input

* fix: address pending input review feedback

* fix: reduce pending input sidebar complexity

* fix: address clarification review comments

* test: cover pending clarification hook
2026-05-02 23:01:21 +01:00
José Almeida 1394b7103f feat: add recent task switcher (#779)
* feat: add recent task switcher

* fix: address task switcher review feedback

* test: cover recent task cleanup

* perf: reduce recent task switcher storage reads
2026-05-02 22:25:40 +01:00
João Salavisa a510b46e4e feat(multi-repo): support tasks spanning multiple repositories (#767)
* feat(task): add task_environment_repos schema and model for multi-repo support

Phase 0 of multi-repository task support: introduces the per-repository
child table and model alongside legacy single-repo fields (dual-write,
no behavior change). Lays the foundation for the orchestrator/lifecycle
refactor in subsequent phases.

- New task_environment_repos table with FK CASCADE on task_environments.
- New task_dir_name column on task_environments (idempotent ALTER).
- TaskEnvironmentRepo model + Repos slice + RepoFor helper on TaskEnvironment.
- CRUD methods + interface extension; env getters preload Repos.
- Idempotent backfill from legacy single-repo task_environments rows.
- Tests cover round-trip, CRUD, preload ordering, embedded create, cascade
  delete, and backfill from a legacy-shaped row.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(lifecycle): add Repositories list to LaunchRequest and EnvPrepareRequest

Phase 1.1 of multi-repository task support: extends the lifecycle types
with explicit per-repo lists, while keeping the legacy single-repo
top-level fields as the synthesized fallback. No caller behavior changes
yet — preparers still see length-1 specs for single-repo launches.

- LaunchRequest.Repositories []RepoLaunchSpec + RepoSpecs() helper.
- EnvPrepareRequest.Repositories []RepoPrepareSpec + RepoSpecs() helper.
- EnvPrepareResult.Worktrees []RepoWorktreeResult for per-repo outcomes.
- runEnvironmentPreparer forwards req.Repositories into the prepare
  request, inheriting the request-level RepoSetupScript when each
  per-repo entry has none.
- Tests cover explicit-list precedence, synthesized fallback, and
  repo-less requests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(worktree,lifecycle): support multiple worktrees per session

Phases 1.2 and 1.3 of multi-repository task support. The pieces are
intertwined so they ship together — the preparer's per-repo loop
depends on the manager being able to hold one worktree per
(session, repository) pair instead of one per session.

Worktree manager:
- In-memory cache key changes from sessionID to (sessionID|repositoryID).
- New MultiRepoStore optional capability + SQLite implementation:
  GetWorktreesBySessionID and GetWorktreeBySessionAndRepository.
- New Manager methods: GetAllBySessionID, GetBySessionAndRepo. Existing
  GetBySessionID returns the first match (single-repo callers unchanged).
- Manager.Create's "already exists" check is now repo-scoped so the
  second repo's create no longer returns the first repo's worktree.
- All cache write/delete sites updated to the composite key.

WorktreePreparer:
- New prepareMultiRepo path activated when EnvPrepareRequest.Repositories
  has >=2 entries; single-repo and repo-less behavior is byte-identical.
- prepareOneRepo runs validate -> sync -> create -> fetch-PR -> per-repo
  setup-script for each spec; rolls back already-created worktrees on
  any failure.
- Workspace path becomes the task root (parent of any repo subdir);
  legacy WorktreeID/WorktreeBranch/MainRepoGitDir mirror Worktrees[0]
  for downstream code that hasn't been migrated yet.
- Per-repo setup script runs verbatim (no script-engine resolution
  yet — the per-repo placeholder namespace lands in a follow-up).

Tests:
- Manager: GetAllBySessionID, GetBySessionAndRepo (hit + miss),
  GetBySessionID single-repo compat, cache key separation.
- Preparer: multi-repo creates one worktree per repo; rollback removes
  active worktrees on partial failure; per-repo setup script runs in
  each worktree directory and emits its own step.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(orchestrator): resolve all task repos and persist per-repo rows on launch

Phase 1.4 of multi-repository task support: the orchestrator now reads
every repository linked to a task, forwards the full list through the
adapter into the lifecycle layer, and persists one TaskEnvironmentRepo
plus one TaskSessionWorktree row per repo when the launch is multi-repo.
Single-repo and repo-less tasks keep their previous code path verbatim
(dual-write — legacy single-repo fields stay populated from the primary).

- New resolveAllRepoInfo / resolveTaskRepoInfo on the executor; primary
  is the first by Position.
- buildLaunchAgentRequest takes both the primary repoInfo and the full
  list, populating req.Repositories []RepoSpec only when length > 1.
- New RepoSpec / RepoWorktreeResult on the orchestrator's executor
  package mirror the lifecycle types so the adapter can translate
  between them without leaking lifecycle types into the orchestrator
  public API.
- Adapter (cmd/kandev/adapters.go) forwards req.Repositories into
  lifecycle.LaunchRequest and surfaces per-repo prepare results back
  on LaunchAgentResponse.Worktrees.
- persistTaskEnvironment writes env.Repos in the same create
  transaction; persistTaskEnvironmentRepos handles the existing-env
  reuse path. TaskEnvironment.TaskDirName is now persisted.
- persistWorktreeAssociation loops over resp.Worktrees in multi-repo,
  inserting one row per repo with its own RepositoryID.
- ResumeSession now also populates req.Repositories from task repos
  and stamps the existing TaskDirName so the preparer reuses the same
  task root.
- repoStore / executorStore interfaces extended with the new repo and
  env-repo CRUD methods; mocks updated accordingly.

Tests:
- LaunchPreparedSession with two task repositories populates
  req.Repositories in order and keeps legacy single-repo top-level
  fields filled from the primary.
- The same launch persists one TaskEnvironment + 2 TaskEnvironmentRepo
  + 2 TaskSessionWorktree rows.
- Single-repo launch still produces zero entries in req.Repositories
  and exercises only the legacy fields.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(agentctl): git endpoints accept optional repo subpath for multi-repo

Phase 2 of multi-repository task support: every per-call git endpoint
gains an optional `repo` field (form param for GET, JSON for POST) that
selects a sub-directory of the workspace. Empty `repo` = workspace root,
preserving single-repo behavior verbatim.

- Manager.GitOperatorFor(subpath) returns a per-subpath GitOperator
  (cached). Validates the subpath against path traversal, absolute
  paths, and missing directories.
- New api server helper gitOpForRepo() emits a 400 with a per-operation
  GitOperationResult on invalid subpaths.
- Repo field added to: pull, push, rebase, merge, abort, commit,
  rename-branch, stage, unstage, discard, create-pr, revert-commit,
  show-commit, reset, log, cumulative-diff. (git/status still reads
  from the workspace tracker — that endpoint will gain multi-repo
  support when the tracker itself is split per repo.)
- Tests cover empty subpath, valid subpath caching, traversal
  rejection, and missing-directory rejection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(web/comments): scope diff comments by repository for multi-repo tasks

Phase 3 of multi-repository task support: comments now carry an
optional repositoryId so two repos with the same file path render
their own comment threads instead of bleeding together.

- CommentBase.repositoryId (optional, backwards compatible).
- getCommentsForFile gains an optional repositoryId arg; legacy
  comments without a repositoryId match any repo filter so persisted
  data keeps showing.
- Diff store interface in lib/diff/types.ts mirrors the new arg.
- Tests cover per-repo filter, omitted-arg fallback, and legacy data.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(github): per-repo PR association on task PRs and watches

Phase 4 of multi-repository task support: github_task_prs and
github_pr_watches gain a repository_id column so multi-repo tasks can
have one PR per repo without colliding on (task_id) uniqueness.

- Idempotent ALTER TABLE adds repository_id to both tables.
- TaskPR.RepositoryID and PRWatch.RepositoryID model fields.
- New GetTaskPRByRepository and ListTaskPRsByTask lookups; existing
  GetTaskPR keeps single-PR semantics for callers not yet migrated.
- ReplaceTaskPR's delete is scoped by repository_id when set so a
  multi-repo task replacing one repo's PR no longer wipes the other.
- Tests cover per-repo storage and scoped replace.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(web): foundations for multi-repo task UI

Phase 5 of multi-repository task support: primitives the upcoming UI
work needs without changing any visible behavior yet.

- TaskRepository.checkout_branch surfaces the optional PR head branch
  field that the backend already exposes.
- primaryTaskRepository(repos) picks by lowest Position so consumers
  match the backend's "primary" semantics instead of `[0]`.
- New useTaskRepositories(taskId) / useTaskPrimaryRepository(taskId)
  hooks read the live kanban task and return its repos sorted by
  position. Future kanban/diff/UI consumers should use these instead
  of poking task.repositories directly.
- Tests cover lowest-position selection and empty/single-repo edge cases.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(web): kanban card shows multi-repo "+N" chip and hydrates full repo list

Phase 6 (subset) of multi-repository task support. The kanban now
exposes the per-task repository list to the UI and renders a compact
"+N" badge on cards whose tasks span more than one repository.

- KanbanState.tasks[].repositories slim shape (id, repository_id,
  base_branch, checkout_branch, position).
- SSR mapper hydrates the full list and uses primaryTaskRepository()
  to pick the position-aware primary instead of [0].
- useKanbanData passes repositories through to the card props.
- KanbanCard renders the primary repo name with a small "+N" chip
  when len(repositories) > 1; tooltip names the count.
- useTaskRepositories / useTaskPrimaryRepository hooks return the
  slim KanbanTaskRepository shape so multi-repo consumers share one
  read path.

Larger UI surfaces (task-create dialog, diff view, file browser,
mobile sheets) still target the primary repo only — they will be
ported as the multi-repo task creation flow lands. No visible change
for single-repo tasks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(task): reject duplicate repositories on multi-repo task create

Phase 8 of multi-repository task support: surface duplicate-repository
input as a clean validation error instead of letting the unique
(task_id, repository_id) constraint surface mid-loop with some rows
already inserted.

- createTaskRepositories tracks seen repository IDs and returns
  "repository <id> listed more than once for the task" before any
  CreateTaskRepository call.
- Tests cover the rejection path and confirm two distinct repos still
  succeed with both rows persisted in Position order.

Repository discovery and per-repo cloning were already covered by
Phase 1.4 (resolveAllRepoInfo loops ensureRepoCloned) and the
existing repository_discovery service is repo-list aware by design.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(web/task-create): repeatable repository rows for multi-repo task creation

Users can now actually create a multi-repo task: a "+ Add repository"
button below the primary repo + branch selectors expands the form with
additional (repository, base-branch) rows. Each extra row picks from a
filtered repository list that excludes the primary and any other already-
chosen extras, so duplicates are impossible to enter.

- ExtraRepositoryRow type + form-state hook (extraRepositories, add /
  remove / update / reset). Lives in its own file so the main state hook
  stays under the file-length lint cap.
- ExtraRepositoryRows component renders the rows and the "+ Add" button;
  per-row branches are loaded on demand via the existing
  useRepositoryBranches hook.
- buildRepositoriesPayload accepts the extras and appends them after the
  primary, dropping rows with empty repository_id silently so an
  unfinished row does not block submit. The backend's duplicate-repo
  validation (Phase 8) catches anything that slips through.
- DialogFormBody is split: CreateModeSelectors (selectors + extra rows)
  and useDialogHandlers move to dedicated files, keeping touched files
  under the lint thresholds.
- Tests cover the multi-repo payload (ordering, drop-empty, no-primary
  recovery, single-repo unchanged) and the existing
  store-multi-repo.test.ts gets named constants to satisfy the
  no-duplicate-string rule.

Verified: pnpm exec tsc --noEmit clean, vitest 608/608 green,
eslint --max-warnings 0 clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(agentctl): per-repo workspace tracker for multi-repo git/status

The git/status endpoint now accepts an optional ?repo=<subpath> query
param that selects a sub-directory of the workspace, completing the
per-repo plumbing started in the multi-repo phase. Single-repo callers
keep the workspace-root behavior unchanged.

- Manager.GetWorkspaceTrackerFor(subpath) returns a per-subpath
  WorkspaceTracker (lazy + cached). Validation reuses a new
  resolveSubpath helper shared with GitOperatorFor, so both code paths
  reject path traversal / absolute paths / missing dirs the same way.
- Per-subpath trackers are NOT started (no polling goroutines); the
  multi-repo path uses them only for synchronous GetCurrentGitStatus
  queries which fall through to getGitStatus when no cache is present.
  This keeps per-repo cost cheap.
- handleGitStatus reads c.Query("repo") and routes through the new
  resolver, returning a 400 with an error payload on invalid input.
- Tests cover empty subpath (root), valid subpath caching, traversal
  rejection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(web/review): group file tree by repository for multi-repo task changes

The review file tree now renders per-repo top-level nodes when a task's
changes span multiple repositories, so the same file path in two repos
no longer collapses into a single ambiguous entry.

- ReviewFile.repository_id and ReviewFile.repository_name (optional)
  identify which repo each file belongs to. Single-repo callers leave
  them unset and the tree falls back to the existing flat grouping.
- buildFileTree splits into buildMultiRepoTree (when 2+ distinct
  repository_names are present) and buildFlatTree (the original logic).
  Repo roots have isRepoRoot=true and a repositoryId for downstream
  filters; their children are full path subtrees per repo.
- collapse() skips repo roots so a repo with a single changed file
  still shows its repo header.
- ReviewFileTree renders repo roots with a stronger label, a top
  border, and an "N files" count badge; data-testid="repo-root-node"
  for E2E coverage. File-level rendering is unchanged.
- Tests cover the flat-tree fallback (no repo info, single repo),
  multi-repo grouping, repo-root pinning, and same-filename-different-
  repos isolation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(web/task-top-bar): "+N" repo chip on multi-repo task header

The task page top-bar breadcrumb now shows a small "+N" chip next to the
primary repository name when the task spans more than one repository,
matching the kanban-card treatment introduced earlier. Hover surfaces the
exact count via tooltip ("frontend (+2 more repos)").

- resolveTaskProps populates repositoryCount from task.repositories.length.
- TaskTopBar threads repositoryCount through to the new
  RepositoryBreadcrumb subcomponent.
- The chip carries data-testid="topbar-extra-repo-count" for E2E
  selectors.
- TopBarLeft was nearing the function-length / complexity caps; the
  breadcrumb extraction restores headroom for future additions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web/task-create): disable "Add repository" when no other repos available

When the user only has the primary repository in their workspace,
clicking "+ Add repository" used to insert a row whose dropdown was
empty — looking like a bug ("no other repos show up"). The button now
disables itself when there are no remaining workspace repositories to
choose from, with a tooltip explaining why ("All workspace repositories
are already added"). The previous "primary not selected" guard is also
surfaced via tooltip.

- ExtraRepositoryRows computes remainingCount = repos − used (primary
  + already-added extras) and gates the Add button on it.
- New computeAddButtonHint helper returns a per-state title string for
  the disabled cases.
- Tests cover: dropdown shows the unused workspace repos when opened
  (the click path the user reported wasn't actually broken — the gate
  was just missing); Add button disabled when only the primary exists.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(agentctl): workspace tracker falls back to first repo subdir for multi-repo task roots

The "Changes" panel was empty for multi-repo tasks because the
agent's workspace path is the task root — a plain holder directory
that holds one git worktree per repository as siblings. The workspace
tracker tried to run git commands against that bare root and silently
failed, so no git status updates ever reached the UI.

The tracker now detects this at construction: when the configured
workDir isn't itself a git repo, it falls back to the first immediate
child directory that is. The user gets the primary repo's changes in
the panel; cross-repo aggregation is a follow-up.

- preferGitRepoChildIfRootIsBare scans os.ReadDir entries (skipping
  hidden / non-directory entries) and returns the first that has a
  valid git index. Logs an info line when the rewrite happens so it's
  visible in the agentctl log.
- Repo-less tasks (e.g. quick chat) and single-repo tasks keep their
  previous behavior verbatim — the fallback only fires when the root
  itself fails resolveGitIndexPath.
- Tests cover: fallback when the workdir is a multi-repo holder,
  no-fallback for a real git repo, no-fallback when no git child
  exists.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(multi-repo): per-repo workspace tracking — Changes panel shows all repos

The agent's workspace tracker is now split per-repository for multi-repo
task roots. Each repo gets its own tracker that polls git independently
and stamps its repository name onto every emitted event. The Changes
panel aggregates files from every repo and groups them under per-repo
headers using the file-tree machinery from Phase 6.

Backend (agentctl):
- GitStatusUpdate / FileListUpdate gain a RepositoryName field. Empty
  for single-repo workspaces (legacy clients see no change).
- WorkspaceTracker carries repositoryName and stamps it onto every
  emitted status/file-list. Two constructors: NewWorkspaceTracker for
  the legacy single-repo path (with the bare-root fallback) and
  NewWorkspaceTrackerForRepo for explicit per-repo trackers.
- Manager scans direct child directories for git repos at construction.
  When 2+ repos are found, builds one tracker per repo subdir on top of
  the root tracker (which stays bound to the bare task root and emits
  nothing useful). Single-repo workspaces are unchanged.
- Manager.SubscribeWorkspaceStream / UnsubscribeWorkspaceStream fan out
  one client subscription across the root + all per-repo trackers via
  AttachWorkspaceStreamSubscriber / DetachWorkspaceStreamSubscriber on
  the tracker. The WS handler switched from the single-tracker subscribe
  to the manager fan-out.
- Tests cover: per-repo trackers spawn for multi-repo task roots,
  single-repo doesn't, both repos emit GitStatusUpdates tagged with
  RepositoryName.

Frontend:
- GitStatusEntry / GitStatusData carry repository_name (optional).
- session-runtime gitStatus.byEnvironmentRepo new map keyed by env id
  then repository name; setGitStatus routes per-repo updates into it
  while still updating the legacy byEnvironmentId for single-repo
  consumers. clearGitStatus drops both maps.
- migrateEnvKeyedData migrates the new per-repo map alongside the
  existing keys when sessionId resolves to an environmentId.
- WS git-status handler forwards repository_name from event.status.
- New useSessionGitStatusByRepo hook returns sorted [{repo, status}]
  pairs for the active session.
- task-changes-panel mergeReviewFiles iterates statusByRepo when
  available, stamping repository_name onto every uncommitted file so
  the Changes panel groups them under per-repo headers (using the
  ReviewFile.repository_name plumbing from Phase 6).
- Tests: per-repo routing, no overwrite on sibling repo update,
  legacy single-repo fallback, clear-on-cleanup.

Verified: pnpm exec vitest run 623/623, eslint --max-warnings 0 clean,
tsc --noEmit clean, make test (backend) clean, make lint 0 issues.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(web/changes-panel): per-repo group headers above each repo's files

The Changes panel now renders a sticky "frontend / N files" header above
the diffs from each repository so the user can visually distinguish
where each change lives. Single-repo tasks render unchanged (no header
shown when there's only one unnamed group).

- ReviewDiffList groups its file list by repository_name (preserving the
  caller's order within each group), then renders one RepoGroupHeader
  per group followed by the existing FileDiffSection list. Header gating
  rule: show when len(groups) > 1 OR the single group has a non-empty
  repository name.
- Group key uses "repo:path" so a file with the same path in two repos
  no longer collides on React's reconciliation.
- groupFilesByRepository + RepoGroupHeader live in a sibling
  review-diff-list-groups.tsx file to keep review-diff-list.tsx under
  the 600-line lint cap.
- Tests cover: no header for plain (no repo_name) files, one header per
  repo for 2+ groups, header shown even for a single named repo, file
  paths land under the right group's section.

Verified: vitest 627/627, eslint clean, tsc clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(lifecycle): propagate RepositoryName through PublishGitStatus

The agentctl WorkspaceTracker stamps every per-repo GitStatusUpdate
with RepositoryName, but the lifecycle PublishGitStatus translation
into GitEventPayload was dropping that field. The orchestrator then
forwarded a status with empty repository_name, the frontend store
routed everything into the empty-key bucket, and the Changes panel's
multi-repo grouping never triggered — no header, no per-repo split.

- GitStatusData gains RepositoryName (omitempty so single-repo
  payloads stay byte-identical on the wire).
- PublishGitStatus copies update.RepositoryName onto the payload.
- Test asserts the field survives the publish → subscribe round trip
  via the in-memory event bus on the GitEvent.<sessionID> subject.

Restart the agentctl process for any running multi-repo task to pick
up the new field; new launches get it automatically.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(web/changes-panel): per-repo group headers in the dockview Changes tab

The Changes tab in the dockview panel (the Files / Changes (N) tab the
user actually interacts with — not the standalone Review dialog) was
still rendering a flat file list, so multi-repo task changes either
collided onto the legacy single-status (showing only one repo's files)
or rendered without grouping.

- FileInfo gains repository_name (optional, single-repo unchanged).
- useSessionGit aggregates files from useSessionGitStatusByRepo when
  multi-repo data is present, stamping each FileInfo with its
  repository_name. Falls back to the legacy single status for
  single-repo workspaces. Aggregation extracted into
  aggregateFilesAcrossRepos to keep useSessionGit under the 100-line
  function lint cap.
- ChangedFile (changes-panel.tsx + changes-panel-timeline.tsx) carries
  repositoryName through to the renderer.
- FileListSection delegates to a new FileListBody that groups rows by
  repositoryName; renders a small per-repo subheader when 2+ repos OR
  any single named repo is present, otherwise falls back to the
  pre-multi-repo flat list (same DOM shape).
- Tests cover: no header for plain (no repo_name) files, headers
  per repo for 2+ groups, single named repo still gets a label.

Restart the backend so the per-repo trackers spawn for in-flight
multi-repo tasks; new launches are unaffected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(multi-repo): per-repo git ops, unified branch endpoint, chip-style task create

Bundles a string of multi-repo fixes and refactors that came up while
testing the feature end-to-end:

- workspace_path stored on multi-repo TaskEnvironment was one level too
  high, so agentctl booted with the wrong WorkDir on cold start; fixed
  in resolveTaskEnvWorkspacePath + GetWorkspaceInfoForSession.
- Stage/Unstage/Discard/Commit/Revert/Reset/Amend now thread `repo`
  through agentctl client → WS handler → frontend hook → SessionGit
  fan-out, so the right git repo is targeted (was running at the task
  root, which isn't a git repo for multi-repo tasks).
- agentctl GitLog is multi-repo aware: when no `repo` is specified and
  per-repo trackers exist, fans out one log per repo and merges sorted
  newest-first with RepositoryName stamped on each commit.
- Per-repo grouping for files (Changes panel) and commits, with
  collapsible group headers; commits section defaults to collapsed.
  Algorithm extracted into one generic groupByRepositoryName helper
  (was copy-pasted three times).
- Task creation dialog rewritten around a unified `fs.repositories[]`
  list — no more "primary vs extras" split. Chip-style row with one
  pill per repo + branch, "+" to add, x to remove, GitHub URL toggle
  inline. Discovered (on-machine) repos appear in any chip's dropdown.
  Removes ~6 fields and 3 helpers from the dialog state.
- Branch listing endpoints collapsed to one:
  GET /workspaces/:id/branches?repository_id=X | path=Y
  Frontend collapses to a single useBranches hook with a tagged-union
  source. Drops two HTTP routes, two service methods, two API client
  funcs, and one whole hook file.

Tests added across the chain: workspace_path resolver, sort-commits-
by-date, manager poll-mode propagation, RepoSubpaths, chip grouping
+ collapse + per-repo isLatest, group-by-repo helper, dialog payload
builder + chip wiring + discovered-repo flow.

* feat(multi-repo): unified per-repo git controls for all VCS operations

Changes panel and top-bar VCS button render per-repo Stage all, Commit,
Unstage all, Push, Pull, Rebase, Merge, and Create PR uniformly across
single-repo and multi-repo workspaces. Operations route to the correct
git repo subpath; fan-outs collect per-repo results and surface partial
success in the toast layer.

Backend:
- Stamp RepositoryName on Reset/BranchSwitch/FileChange notifications
  and forward through orchestrator handlers
- Surface per-repo errors from handleGitLogMultiRepo via PerRepoErrors

Frontend:
- pull/push/rebase/merge/abort/commit fan out via PerRepoOperationResult
- Per-repo group headers always render with action buttons
- Top-bar dropdown lists per-repo Pull/Rebase/Merge entries
- pendingStageFiles keyed per-repo to prevent cross-repo clears
- useRepoDisplayName distinguishes loading from multi-repo
- Commit dialog file summary respects stageAll
- New helpers: changes-panel-helpers.ts, changes-panel-repo-groups.tsx,
  use-repo-display-name.ts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(multi-repo): plumb repo through file/commit/diff endpoints; per-repo PRs and review UI

- backend: thread `repo` subpath through pull/push/rebase/merge/abort, file
  content/update, cumulative-diff, and commit-show endpoints; per-repo
  fan-out for log + cumulative-diff with each repo's own merge-base; emit
  full `repositories` array on task.updated WS payload
- github: store/list TaskPRs as []*TaskPR (per-repo); orchestrator/web store
  rekey to `Record<taskId, TaskPR[]>`
- web: aggregate PR icon (count badge + worst-of color, multi-PR tooltip);
  branch hover card renders one row per repo with each repo's task base
  branch; review dialog merges per-repo file maps with composite keys and
  propagates repository_name through diff/expand/revert; commit-detail
  panel scopes to the owning repo
- kanban card: chip row of repo names (up to 2 + "+N" overflow, tooltip
  lists all)
- task-create dialog: gate non-worktree executors to multi-repo only; move
  fresh-branch toggle inline next to the repo chips (icon-only, with
  title/aria-label)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(multi-repo): scope PR watches/associations and review UI per repository

Schema: add repository_id to UNIQUE constraints on github_pr_watches and
github_task_prs (with idempotent copy-and-rename migration) so one task
spanning N repos can hold one watch/PR per repo without collisions.

Plumb repository_id through CreatePRWatch/EnsurePRWatch/AssociatePRWithTask
/AssociatePRByURL, fan TriggerPRSync across all per-repo watches, and
disambiguate status sync by (task, owner, repo, pr_number) instead of
"first match". Add Repo to wsCreateFile/Delete/Rename and wsCreatePR/
RenameBranch so file and git ops target the right working tree.

Frontend review dialog: switch reviewedFiles, staleFiles, fileRefs,
selectedFile and comment counts to NUL-separated `${repo}\x00${path}`
keys so same-named files in different repos no longer share state.
useTaskPR consumes the new {prs: TaskPR[]} envelope.

Also: snapshot repoTrackers slice before background refresh in
SetWorkspacePollMode to prevent races with Stop(), and key
mergeCumulativeFiles by NUL-separated (repo, path) to match.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: apply prettier formatting

Unblock CI prettier check on files modified earlier on this branch
that pre-date the current prettier config.

* fix: address PR review feedback and unblock E2E

Backend:
- ListWorkspaceTaskPRs background sweep now uses TriggerPRSyncAll so
  multi-repo tasks refresh every per-repo PR (was only the most recently
  updated watch). Caught by claude review on #767.

Frontend (no behaviour change):
- Replace literal NUL byte (0x00) in FILE_KEY_SEP and the inline key
  builder in use-review-dialog with the explicit   escape so the
  source is plain ASCII. The runtime separator is unchanged; this just
  removes a footgun that GitHub's diff renders as a visible space.
- Show the auto-resolved branch as a small read-only pill next to the
  GitHub URL input so the URL-mode dialog surfaces the branch the task
  will run on (previously only the URL was visible).

E2E:
- changes-panel-focus / pr-detection / pr-switcher-changes: expand the
  commits-section before asserting on commit text -- the section starts
  collapsed since the chip-style refactor.
- repository-selector-scroll: open the popover via repo-chip-trigger
  instead of the legacy single repository-selector testid.

* test(e2e): align task-create dialog tests with chip refactor

Updates three more tests that broke when the dialog migrated from a
single repository/branch combobox to per-row repo/branch chips and the
URL-mode toggle relabel:

- create-task.spec.ts (pre-select + remembers-selections): switch
  repository-selector / branch-selector lookups to repo-chip-trigger /
  branch-chip-trigger.
- create-task-github-url.spec.ts (toggle test): the inverse-state label
  is now "use a workspace repository", not the legacy
  "or select a repository".

No code changes — purely test alignment with the existing UI.

* fix(web): re-apply workbench-chrome topbar + restore branch-selector behaviour

Two regressions surfaced by the multi-repo merge into main:

1. task-top-bar.tsx had been resolved in our favour at the workbench-chrome
   merge (#761), losing the TopbarActionOverflow wiring that the new test
   asserts on. Re-applies main's topbar structure (TopbarCluster groups,
   TopbarActionOverflow, IntegrationsMenu, MoreToolsMenu, SettingsButton)
   on top of our multi-repo additions: repositoryCount + RepositoryBreadcrumb
   "+N" chip, gitStatus + isMultiRepo plumbing, and the GitAheadBehindBadges
   suppression for multi-repo. GitAheadBehindBadges moved to its own file to
   stay under the file-length cap.

2. The chip-style task-create dialog dropped the legacy branch-selector
   semantics. Restored:
   - Local-executor branch lock: the per-row branch chip is now disabled
     when isLocalExecutor and fresh-branch is off. The user's checkout
     dictates the branch for local execution; fresh-branch unlocks it.
   - Branch override: when locked, the chip shows currentLocalBranch
     instead of the value the user picked under a different executor
     (so worktree -> local doesn't display a stale "develop" pick).
   - Branch refresh: useBranches now exposes a refresh() callback for
     id-based sources (calls listRepositoryBranches with refresh=true),
     and Pill renders BranchRefreshButton in its popover header when
     onRefresh is wired.
   - URL-mode branch picker: GitHubUrlSection now renders a real
     branch-chip-trigger Pill backed by fs.githubBranches instead of a
     read-only span, restoring the "branch selectable in URL mode"
     behaviour (URL mode is always remote-clone, so the local-executor
     lock does not apply).

E2E spec updated: bulk-rename legacy branch-selector / repository-selector
testids to branch-chip-trigger / repo-chip-trigger.first(). All 13
create-task-branch-selector tests pass locally.

* ci: force workflow re-run

* fix(web): unblock E2E shard 2

Three issues showed up only after the merge with main, which brought back
the E2E shard 2 tests touching git/changes-panel and improve-kandev:

1. Wire the TaskCreateDialog slot props (extraFormSlot, aboveDescriptionSlot,
   bottomSlot, descriptionPlaceholder) through to DialogPromptSection. They
   were declared on the public API but dropped on the floor before this
   change, so Improve Kandev's contributor banner ("@octocat" /
   "@kandev-maint") never rendered. Also gate the "or paste a GitHub URL"
   toggle on lockedFields.repository so the locked-repo dialog hides it
   (matches main's behaviour and the existing test expectation).

2. Revert "commits section starts collapsed" (introduced in 9e8079de). The
   collapsed-by-default tradeoff is genuine, but it broke 14+ E2E tests
   that assert on commit text in the changes panel — the win wasn't worth
   the test churn. Updated the unit test to match.

3. Update the git-changes-panel E2E tests to use the stable
   `[data-testid^="commit-row-"]` selector instead of `commitsList li`.
   The chip-style refactor introduced a per-repo group <li> wrapper, so
   counting all <li> descendants now over-counts (1 wrapper + N commits).
   Same fix in the "distinguishes pushed and unpushed" test which queried
   `li` with text and matched both the wrapper and the commit row.

Locally verified: full shard 2 (45 tests) passes; full unit-test suite
(943 tests) passes; lint + typecheck clean.

* fix: address claude review feedback

Three findings from claude[bot] review on PR #767:

- types.ts: add 13 unit tests for reviewFileKey / splitReviewFileKey
  covering bare paths, composite keys, slashes/spaces in paths, repo
  names containing slashes, and full round-trip. Asks were repeated
  across five review rounds; the round-trip coverage is what catches
  the FILE_KEY_SEP NUL-vs-space class of bug at the type level.

- mergeCumulativeFiles (api/git.go): shallow-copy each per-file payload
  before stamping repository_name + path so the source map isn't
  mutated. The previous code wrote directly into the per-repo result
  map, which would have permanently rewritten the original entry if
  any future caller iterated the same source twice.

- buildRepoSpecs (executor_execute.go / executor_resume.go): drop the
  unused execCfg parameter (and the speculative "reserved for future
  per-repo config" suppression). Per repo overrides do not exist
  today and the speculative scaffolding masked an actual dead arg.

* docs(readme): mention multi-repository task support

* fix(web/topbar): drop standalone ahead/behind badges (duplicate of split button)

The Create PR / Push button already shows the inline ↑N ↓N counts, so
the separate "3 ahead" / "2 behind" badges next to the PR icon were
just visual noise. Removes the GitAheadBehindBadges component (now
unused) and the gitStatus / isMultiRepo prop chain that fed it.

* fix(github): heal legacy repository_id='' rows on github_task_prs

A task that pre-dated the multi-repo schema kept its github_task_prs row
with repository_id=''. After the schema migration, a subsequent sync
would resolve the task's primary repository_id and CreateTaskPR with the
new value -- but GetTaskPRByRepository(task, repo) returned nil because
the legacy row had an empty repository_id, so the new code inserted a
duplicate row instead of updating the legacy one. Net effect: two rows
for the same PR, surfacing as a "+2" PR badge on a single-repo task.

Adds backfillTaskPRsRepositoryID, run on schema init right after
migratePRTablesForMultiRepo:

  1. Dedup pass: when a (task, owner, repo, pr_number) tuple has both a
     legacy (repository_id='') and a per-repo row, drop the legacy one.
  2. Backfill pass: any remaining empty-repo rows get stamped with the
     task's primary repository_id from task_repositories.

The backfill is gated on task_repositories existing -- github-store unit
tests init only this package's schema, and the dedup pass alone is the
load-bearing fix for the symptom.

Tests cover the dedup path, the cross-package-skip path, and idempotency.

* test(github): cover UPDATE backfill path for orphan legacy rows

Adds TestBackfillTaskPRsRepositoryID_BackfillsOrphanRow per claude
review feedback. The two prior tests cover the DELETE dedup path and
the early-exit when task_repositories is absent, but the UPDATE path
that stamps the primary repository_id onto an orphan legacy row had
no coverage -- a wrong column name or off-by-one WHERE would have
slipped through silently.

* feat(web/changes-panel): group PR Changes by repository

Multi-repo tasks now have one TaskPR per repo, but the PR Changes
section was still rendering files flat using only the primary PR.
Adds useActiveTaskPRsWithFiles, which fans out one WS request per
TaskPR and tracks each fetch by (owner/repo/pr_number/last_synced_at)
so a server-side sync invalidates the cache.

Each PR's files are stamped with the resolved repository_name (looked
up from the workspace repos slice), and PRFilesGroupedList groups by
that name -- mirroring how Commits and Changes already render. Single-
repo tasks (one group, empty name) still render flat.

Mechanics:

- PRChangedFile gains an optional repository_name.
- mapPRFilesToChangedFiles takes a repository_name and stamps it.
- New PRFilesGroupedList + PRFilesRepoGroup live in
  changes-panel-pr-files.tsx so changes-panel-timeline.tsx stays
  under the 600-line cap; PRFilesSection delegates to them.
- Hook uses refs (not state) for the in-flight / fetched dedup so
  setState is only called when results arrive.
- Stable EMPTY_PRS sentinel avoids the new-array-each-render trap
  that would re-trigger every effect downstream.
- Workspace repo lookup memoised once via useMemo so the merged file
  list isn't rebuilt on every render.

* fix(web/changes-panel): show per-repo PR header when task is multi-repo

Previous logic only stamped repository_name on PR files when prs.length > 1
(2+ PRs). For a multi-repo task with only ONE PR yet (one repo doesn't
have a branch / hasn't been pushed), no per-repo header rendered, even
though STAGED and COMMITS already grouped under their repo names. Now
gates the stamp on the task itself being multi-repo, matching how the
other sections decide.

* fix(executor): serialise concurrent LaunchPreparedSession per session

Two paths in task_operations.go call LaunchPreparedSession on the same
session_id:

  1. PrepareTaskSession spawns a background goroutine with launchWorkspace=
     true the moment a session is created (so file browsing works before
     the user clicks Start).
  2. StartTaskWithSession runs a foreground launch when the agent is
     actually started (workflow auto-start, user click).

Without serialisation both reach agent_manager.LaunchAgent in parallel.
The fast-path check at the top of LaunchPreparedSession (session.Agent
ExecutionID != "") only kicks in once the first call has *persisted*
its execution id; the second call grabs a stale snapshot, runs the full
env-prep + builds runtime, then loses the executionStore.Add race and
fails with "already has an agent running (race resolved during register)".
Visible in the UI as "Environment setup failed".

Multi-repo amplifies the window because env prep runs sequentially per
repo (~2x the time for two repos), so what was a rare flake on
single-repo became consistent on multi-repo tasks.

Fix: acquire the per-session lock (the same sync.Map used by the resume
path) at the entry of LaunchPreparedSession and re-fetch the session
under the lock so the fast-path check sees the AgentExecutionID the
first holder just persisted. Second caller now takes the existing-
workspace path and starts only the agent subprocess, matching the
intended single-launch semantics.

Adds TestLaunchPreparedSession_SerialisesConcurrentLaunches that fires
two concurrent LaunchPreparedSession calls with the first one blocked
inside LaunchAgent. Without the lock, both reach LaunchAgent and the
test fails with call_count == 2; with the lock only the first does.

* fix(orchestrator): per-repo push detection so each repo's PR gets associated

Multi-repo tasks would only get one PR detected per task: the agent
pushes branches in BOTH repos, but the pushTracker keyed by sessionID
alone meant agentctl's per-repo git status events overwrote each
other's ahead counts. Whichever repo's events interleaved unfavourably
lost its >0 -> 0 transition and silently dropped its push detection.
Net effect: only the first/last repo's PR got an associated TaskPR row;
the other PR existed on GitHub but Kandev never knew about it.

Fix:

- pushTracker is now keyed by "<sessionID>|<repository_name>" so each
  repo's transitions are tracked independently. Empty repository_name
  preserves the legacy single-repo behaviour.
- trackPushAndAssociatePR forwards data.Status.RepositoryName to
  detectPushAndAssociatePR.
- detectPushAndAssociatePR resolves owner/name/repository_id for the
  specific repo via new resolvePushRepo helper (looks up the task's
  task_repository whose repository.Name matches the event), then uses
  GetPRWatchBySessionAndRepo to scope the existing-watch check.
- New associatePRFromPushScoped takes the per-repo repository_id
  directly instead of always resolving the primary one. CheckSessionPR
  (manual "check now") wired through the same scoped helper.

Existing tests pass with the explicit empty repository_name argument.
The user's existing task with the missing kandev PR row needs a manual
push retrigger or DB heal — the migration only deduped legacy empty
rows; this fix prevents the gap from recurring on future tasks.

* feat(web): one PR button per repo in topbar + per-PR detail panels

PRTopbarButton previously rendered just the primary TaskPR via
useActiveTaskPR(), so multi-repo tasks with N PRs only surfaced one.
Switch to useTaskPR(activeTaskId).prs and render one button per PR --
each opens its own dockview panel keyed by owner/repo/pr_number.

Plumbing:

- dockview-store / dockview-panel-actions: addPRPanel(prKey?: string)
  builds id "pr-detail|<key>" when a key is supplied; the legacy
  single-repo "pr-detail" id stays for the auto-add path.
- dockview-desktop-layout: forwards `params` to PRDetailPanelComponent
  so the panel can receive its prKey.
- pr-detail-panel: new prTaskKey helper + the panel looks up the
  matching TaskPR from the per-task list when params.prKey is set,
  falling back to useActiveTaskPR() for legacy panels and tooltips.
- pr-topbar-button: rewritten to render a button per PR, each labelled
  with #pr_number and tooltipped with owner/repo + title; click opens
  the per-PR panel.

* feat(web): collapse multi-PR topbar to a dropdown + show every PR in the + menu

Two follow-ups on top of per-PR panels:

- The dockview "+" panel-add menu still listed only state.pr (the primary
  TaskPR), so a 2-repo task only offered "PR #2" -- never the kandev
  one. Switch to state.prs[] and render one menu item per PR; multi-repo
  rows are labelled "PR #N -- repo" so the user can tell them apart.
  Each item opens its own panel via addPRPanel(prTaskKey(pr)).

- The topbar grew one outline button per PR after the previous commit,
  which doesn't scale (3+ PRs would crowd out other actions). Single PR
  keeps the existing single-button rendering; 2+ collapses to one
  outline button labelled "N PRs" with the aggregated status colour
  (red if any failing, etc.) and a dropdown listing each PR with repo,
  number, title, and per-PR status icon. Click an item -> open that
  PR's panel.

useLeftHeaderState now reads useTaskPR(taskId).prs and forwards the
full list through AddPanelMenuState as `prs`.

* fix(web/changes-panel): PR files arrive after race-cancelled fetch

useActiveTaskPRsWithFiles dropped responses from the previous effect
instance via a `cancelled` flag, but since the dedup tracking refs
(inFlightRef / fetchedRef) carry across renders, the next effect's
early-continue still saw the key in inFlightRef and skipped a fresh
request. The ref then got populated by the cancelled response and
fetchedRef permanently locked the key out of any future fetch.

Net effect: every render after the first prs reference change left
the merged prFiles array empty -- the PR Changes section never showed
up on multi-repo tasks even though both PRs existed and the WS request
would have returned files.

Drop the cancellation flag entirely. Per-key dedup is enough to prevent
duplicate requests, and the .then handlers use functional setState so
they're safe to land after the effect re-runs.

* test: cover review-feedback gaps (mapPRFilesToChangedFiles, push multi-repo, lock test channel-sync)

Three follow-ups from claude review on PR #767:

1. apps/web/components/task/changes-panel-helpers.test.ts: 5 new tests
   for mapPRFilesToChangedFiles. Covers status mapping (added / removed /
   renamed / modified / fallback), forwarding of plus/minus/oldPath, and
   the new repository_name stamping behaviour (multi-repo path stamps
   every row; single-repo path defaults to "" so PRFilesGroupedList
   skips per-repo sub-headers).

2. event_handlers_github_test.go: TestDetectPushAndAssociatePR gains
   two multi-repo cases. resolvePushRepo must scope CreatePRWatch +
   AssociatePRWithTask to the repository_id matching the event's
   repository_name (not silently fall back to primary); unknown
   repository_name must drop the event rather than mis-associate.
   mockGitHubService captures lastCreateWatchRepositoryID /
   lastAssociateRepositoryID so the per-repo scoping is asserted
   directly.

3. executor_test.go: TestLaunchPreparedSession_SerialisesConcurrentLaunches
   replaces the time.Sleep(50ms) coordination with a buffered `entered`
   channel + bounded select wait per CLAUDE.md. The test now waits for
   the first launch to enter LaunchAgent, then asserts no second entry
   arrives within a 100ms window — without the lock the second
   goroutine reaches LaunchAgent in microseconds, so the timeout serves
   as the "didn't race in" oracle, not a synchronisation primitive.

* fix(orchestrator): detect push when first poll lands post-sync

trackPushAndAssociatePR only fired on a >0→0 ahead transition, which
required two distinct status events to be observed. In multi-repo tasks,
any repo whose first agentctl status poll happened to land after the
push had already completed (ahead=0 with remote branch set on the very
first observation) silently dropped the push event — the watch was
never created, the PR never associated. Reproduced on task
4fdff41b-095a-4158-a311-4a1a23abe064 where 2/3 repos pushed but only
one PR appeared.

Now also fire on first observation when the status already reflects a
post-push state (ahead=0 + RemoteBranch set). detectPushAndAssociatePR
is idempotent against an existing watch, so a spurious fire on a
worktree that started from an already-synced remote just costs one
GitHub FindPRByBranch call.

The deferred pushTracker.Delete in detectPushAndAssociatePR is removed:
keeping ahead=0 stored is what suppresses repeated firing on subsequent
status events. Cleanup now happens at session-delete time via a new
pushTrackerForget helper that walks all (session|repo) entries.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web/topbar): restore pr-topbar-button testid for single-PR case

The multi-PR refactor renamed PRSingleButton's testid from
pr-topbar-button to pr-topbar-button-${pr_number}, breaking E2E tests
that relied on the stable selector (task-topbar-overflow.spec.ts and
the prTopbarButton page-object helper used by vcs-button-context).

Restore the canonical testid and expose pr_number via data-pr-number
for any test that needs to discriminate. PRMultiButton already uses
the same testid; with at most one PR-area control rendered at a time,
sharing the testid is unambiguous.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(orchestrator): create one PR watch per repo on multi-repo tasks

The orchestrator only ever ensured a watch for a task's PRIMARY
repository. Both call sites that create watches were primary-only:

  - ensureSessionPRWatch (called when a session starts/resumes) used
    resolvePrimaryTaskRepositoryID and made one EnsurePRWatch call.

  - buildTaskBranchList (used by the poller's reconciler) keyed dedup
    by sessionID alone, so as soon as the primary repo got a watch
    the whole session was marked "watched" and the poller skipped
    every other repo.

Result: in a multi-repo task, the secondary repos never got a watch
row, so the periodic GitHub poller never searched for their PRs and
never associated them. Push detection (which fires on >0→0 ahead
transitions vs origin/main) doesn't help here either because Ahead is
measured against the integration branch, not the upstream tracking
branch — pushing doesn't change Ahead.

Reproduced on task a7a59bd8: PRs existed on github for both kandev
and aistats, but only the primary (kandev) was associated.

Refactor:

  - resolveSessionWatchTargets walks task_repositories × session
    worktrees and returns one (repo_id, owner, repo, branch) target
    per repo. Branch priority preserves the legacy precedence
    (checkout_branch > primary fallback > worktree branch) so PR-
    review-style synthetic worktree names don't clobber the real one.

  - ensureSessionPRWatch loops over targets and ensures one watch per
    repo.

  - buildTaskBranchList emits one TaskBranchInfo per (session, repo)
    and dedups against (session_id, repository_id) pairs from
    ListActivePRWatches — replacing the old session-only dedup that
    suppressed non-primary repos.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 20:26:42 +01:00